关联模块:
app/api/v1/export.py·app/api/v1/export_records.py·app/services/export_service.py·app/services/export_record_service.py·app/services/storage_monitor.py·app/schemas/export.py关联章节:text-editor-backend-api.md § 3. 导出 API(阶段 0)
| 项目 | 内容 |
|---|---|
| 功能 | 将文档的 Markdown 内容转换为 .doc 文件,持久化保存并提供永久下载链接 |
| 引入阶段 | 阶段 0 |
| Base URL | http://192.168.0.200:8000 |
| 涉及端点 | POST /api/v1/export/doc · GET /api/v1/export/records · GET /api/v1/export/records/{recordId}/download · DELETE /api/v1/export/records/{recordId} · GET /api/v1/admin/storage |
| 核心依赖 | python-docx 1.1.2 · mistune 3.0.2 |
| 文件存储 | 服务器本地 ./tmp/{user_id}/{YYYY-MM-DD}/ 目录,按用户、按天分区,永久保留 |
| 下载链接 | 永久有效,无过期时间 |
| 默认样式文件 | ./tmp/default.json(阶段 0 固定使用;阶段 1 起支持用户选择) |
| 磁盘监控 | 后台定时任务每 30 分钟检查一次;每次导出后也触发实时检查 |
POST /api/v1/export/doc
用户在编辑器点击"下载"按钮后触发。后端先检查该文档是否有更新,若无更新直接返回已有记录链接;若有更新则重新生成 .doc 文件,写入新的下载记录并返回新链接。
文件名由后端自动生成:取文档内容第一行文本 + 当前时间戳,前端无需传入。
Headers
| Key | Value |
|---|---|
Content-Type |
application/json |
Authorization |
Bearer <token>(复用宿主应用认证) |
Body
{
"documentId": "doc-abc123",
"styleId": null
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
documentId |
string | 是 | 关联文档 ID,后端据此读取内容并判断是否需要重新生成 |
styleId |
string | 否 | 样式 ID;阶段 0 暂不生效,后端固定使用 default.json;阶段 1 起传入有效 ID 则使用用户上传的样式 |
200 成功(正常)
{
"code": 0,
"message": "Success",
"data": {
"recordId": "rec-abc123",
"downloadUrl": "http://192.168.0.200:8000/api/v1/export/records/rec-abc123/download",
"fileName": "2026年Q2季度报告_1680000000000.doc",
"styleId": "default",
"warning": null
}
}
200 成功(存储超限)
{
"code": 0,
"message": "Success",
"data": {
"recordId": "rec-abc123",
"downloadUrl": "http://192.168.0.200:8000/api/v1/export/records/rec-abc123/download",
"fileName": "2026年Q2季度报告_1680000000000.doc",
"styleId": "default",
"warning": "您的存储空间已超出限额,请删除旧文件释放空间"
}
}
| 字段 | 类型 | 说明 |
|---|---|---|
recordId |
string | 下载记录 ID,可用于重新下载或删除 |
downloadUrl |
string | 永久下载链接 |
fileName |
string | 含扩展名的完整文件名,格式:{内容第一行}_{时间戳}.doc |
styleId |
string | 实际使用的样式 ID;阶段 0 固定返回 "default" |
warning |
string | null | 存储超限时附带警告信息,正常时为 null |
错误响应
| HTTP 状态码 | code | 触发条件 |
|---|---|---|
| 404 | 404 | documentId 不存在 |
| 500 | 500 | Markdown 解析失败 / 文件写入失败 |
GET /api/v1/export/records
分页查询当前用户的全部导出记录。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
userId |
string | 是 | 当前用户 ID(Query 参数) |
page |
integer | 否 | 页码,默认 1 |
pageSize |
integer | 否 | 每页数量,默认 20,最大 100 |
sortOrder |
string | 否 | asc / desc,默认 desc |
{
"code": 0,
"data": {
"records": [
{
"recordId": "rec-abc123",
"userId": "user-xyz",
"fileName": "2026年Q2季度报告_1680000000000.doc",
"fileSize": 204800,
"downloadUrl": "http://192.168.0.200:8000/api/v1/export/records/rec-abc123/download",
"documentId": "doc-abc123",
"styleId": "default",
"createdAt": 1680000000000
}
],
"pagination": {
"page": 1,
"pageSize": 20,
"total": 5,
"totalPages": 1
}
}
}
GET /api/v1/export/records/{recordId}/download
根据记录 ID 查找本地文件,触发浏览器下载。后端查询时同时校验 recordId + userId,查不到一律返回 404。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
recordId |
string | 是 | 路径参数 |
userId |
string | 是 | 当前用户 ID(Query 参数) |
200 成功
Content-Type: application/msword
Content-Disposition: attachment; filename="2026年Q2季度报告_1680000000000.doc"
<二进制文件内容>
错误响应
| HTTP 状态码 | code | 触发条件 |
|---|---|---|
| 404 | 4041 | 记录不存在、文件已被删除,或 recordId 与 userId 不匹配 |
DELETE /api/v1/export/records/{recordId}
同步删除磁盘文件和数据库记录(硬删除)。后端查询时同时校验 recordId + userId,查不到一律返回 404。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
recordId |
string | 是 | 路径参数 |
userId |
string | 是 | 当前用户 ID(Query 参数) |
{
"code": 0,
"message": "Record deleted successfully"
}
错误响应
| HTTP 状态码 | code | 触发条件 |
|---|---|---|
| 404 | 4041 | 记录不存在,或 recordId 与 userId 不匹配 |
GET /api/v1/admin/storage
查看 tmp/ 目录总占用、各用户占用及配额情况。
{
"code": 0,
"data": {
"diskTotalBytes": 107374182400,
"diskUsedBytes": 53687091200,
"tmpTotalBytes": 10737418240,
"quotaBytes": 53687091200,
"quotaExceeded": false,
"activeUsers": 5,
"perUserQuotaBytes": 10737418240,
"users": [
{
"userId": "user-xyz",
"usedBytes": 204800,
"quotaExceeded": false
}
]
}
}
| 字段 | 说明 |
|---|---|
diskTotalBytes |
服务器磁盘总容量(字节) |
diskUsedBytes |
磁盘已使用容量(字节) |
tmpTotalBytes |
tmp/ 目录当前占用(字节) |
quotaBytes |
tmp/ 目录配额上限,即磁盘总量的 50% |
quotaExceeded |
tmp/ 总占用是否超过配额 |
activeUsers |
tmp/ 下实际有文件的用户数 |
perUserQuotaBytes |
每人均分配额,即 quotaBytes ÷ activeUsers |
users[].usedBytes |
该用户目录占用(字节) |
users[].quotaExceeded |
该用户是否超过均分配额 |
POST /api/v1/export/doc
|
v
参数校验(ExportDocRequest)
- documentId 不为空
|
v
查询文档记录(documents 表)
- 文档不存在 → 返回 404
|
v
查询最新导出记录(export_records 表)
- 有记录 且 document.updated_at <= record.created_at?
Yes → 直接返回已有记录的 download_url,不重新生成
No → 继续生成新文件
|
v
┌─────────────────────────────┐
│ 样式文件解析 │
│ │
│ styleId 传入且有效? │
│ Yes → 从样式库加载对应 │
│ JSON 文件 │
│ No → 加载默认样式文件 │
│ ./tmp/default.json │
└─────────────┬───────────────┘
│
v
export_service.export_doc()
1. load_style_definitions(style_file)
2. markdown_to_docx_bytes(content, styles)
3. 生成文件名:{内容第一行}_{时间戳}.doc
4. 写入 ./tmp/{user_id}/{YYYY-MM-DD}/{fileName}.doc
5. 写入 export_records 数据库记录
|
v
storage_monitor.check_quota(user_id)
检查 tmp/ 总占用 > 磁盘 50%?
→ 是:记录管理员日志
检查该用户目录 > 均分配额?
→ 是:响应附带 warning 字段
|
v
返回 ExportDocResponse
{ recordId, downloadUrl, fileName, styleId, warning }
应用启动
│
├── 后台定时任务(每 30 分钟)
│ │
│ v
│ 扫描 tmp/ 总占用
│ │
│ ├── 超 50%?→ 管理员日志告警
│ │
│ └── 遍历各用户目录
│ │
│ └── 超均分额度?→ 管理员日志告警
│
└── 每次导出后触发一次实时检查(同上逻辑,超限时额外在响应 warning 字段通知用户)
请求携带 styleId(阶段 1)
└─ styleId 有效 → 使用用户样式库中对应的 JSON 文件
└─ styleId 无效 → 返回 404,不降级
请求未携带 styleId(阶段 0 / 默认)
└─ 加载 ./tmp/default.json(由 DEFAULT_STYLE_FILE 配置)
└─ default.json 不存在 → 返回 500
default.json)| 项目 | 内容 |
|---|---|
| 文件路径 | ./tmp/default.json(由 DEFAULT_STYLE_FILE 配置) |
| 格式 | 样式 JSON,与用户上传文档提取的格式完全相同 |
| 来源 | 从内置标准 Word 文档提取,随服务部署一同发布 |
| 阶段 0 行为 | 所有导出请求固定使用此文件,不支持切换 |
| 阶段 1 行为 | 用户未传 styleId 时作为兜底;用户可上传文档生成新样式并选用 |
{
"source_file": "default.doc",
"total_styles": 164,
"styles": [
{
"name": "Normal",
"style_id": "Normal",
"type": "PARAGRAPH (1)",
"builtin": true,
"hidden": false,
"quick_style": true,
"priority": null,
"base_style": null,
"next_paragraph_style": "Normal",
"font": {
"name": "宋体",
"size_pt": 12.0,
"bold": null,
"italic": null,
"underline": null,
"color_rgb": null,
"strike": null,
"all_caps": null,
"small_caps": null
},
"paragraph_format": {
"alignment": null,
"left_indent_pt": null,
"right_indent_pt": null,
"first_line_indent_pt": null,
"space_before_pt": null,
"space_after_pt": null,
"line_spacing": null,
"keep_together": null,
"keep_with_next": null,
"page_break_before": null
}
},
{
"name": "Heading 1",
"style_id": "Heading1",
"type": "PARAGRAPH (1)",
"font": { "size_pt": 14.0, "bold": true, "color_rgb": "365F91" },
"paragraph_format": { "space_before_pt": 24.0, "space_after_pt": 0.0 }
}
// ... 其余 162 个样式定义
]
}
def load_style_file(style_id: str | None) -> dict:
"""
阶段 0:style_id 始终为 None,返回 default.json
阶段 1:style_id 有值时从样式库查找对应 JSON 路径
"""
if style_id is None:
path = settings.default_style_file # ./tmp/default.json
else:
# 阶段 1:从数据库查路径,找不到抛 404
path = style_repo.get_file_path(style_id)
with open(path, encoding="utf-8") as f:
return json.load(f)
def build_style_map(style_data: dict) -> dict[str, dict]:
"""将样式列表转换为双键映射(style_id 和 name 均可命中),加速查询"""
styles = style_data.get("styles", [])
by_id = {s["style_id"]: s for s in styles if s.get("style_id")}
by_name = {s["name"]: s for s in styles if s.get("name")}
return {**by_id, **by_name}
导出时,DocxRenderer 按以下规则从样式映射中读取属性并应用到 python-docx 对象:
| 优先级 | 规则 |
|---|---|
| 1 | 使用样式 JSON 中对应 style_id 的字体和段落格式 |
| 2 | JSON 中字段为 null 时,保留 python-docx 内置 Word 样式默认值 |
| 3 | 找不到对应 style_id 时,回退到 Normal 样式 |
| Markdown | Word 样式 | python-docx 调用 |
|---|---|---|
# 文字 |
Heading 1 | doc.add_heading(text, level=1) |
## 文字 |
Heading 2 | doc.add_heading(text, level=2) |
### 文字 |
Heading 3 | doc.add_heading(text, level=3) |
#### 文字 |
Heading 4 | doc.add_heading(text, level=4) |
| 普通段落 | Normal | doc.add_paragraph(text) |
| Markdown | 语义 | python-docx 处理 |
|---|---|---|
**文字** |
粗体 | run.bold = True |
*文字* / _文字_ |
斜体 | run.italic = True |
~~文字~~ |
删除线 | run.font.strike = True |
`代码` |
行内代码 | run.font.name = "Courier New" |
| Markdown | Word 样式 | python-docx 调用 |
|---|---|---|
- 文字 / * 文字 |
List Bullet | doc.add_paragraph(style="List Bullet") |
1. 文字 |
List Number | doc.add_paragraph(style="List Number") |
| 二级无序缩进 | List Bullet 2 | doc.add_paragraph(style="List Bullet 2") |
| 二级有序缩进 | List Number 2 | doc.add_paragraph(style="List Number 2") |
| Markdown | Word 样式 | python-docx 处理 |
|---|---|---|
> 文字 |
Quote | doc.add_paragraph(style="Quote") |
代码块 |
No Spacing | 字体 Courier New 10pt,颜色 #333333 |
--- / *** |
段落底部边框 | OxmlElement 注入 w:pBdr |
\| 列 \| 列 \| |
Table Grid | doc.add_table(),表头行自动加粗 |
mistune 3.x 解析表格后的 token 层级(需启用 table 插件):
table
├── table_head
│ ├── table_cell { head: true } <- 直接是 table_cell,无 table_row 层
│ └── table_cell { head: true }
└── table_body
└── table_row
├── table_cell { head: false }
└── table_cell { head: false }
table_head下直接是table_cell,不经过table_row;table_body下才有table_row -> table_cell两层结构。
| 项目 | 内容 |
|---|---|
| 存储路径 | ./tmp/{user_id}/{YYYY-MM-DD}/(按用户、按天分区) |
| 文件命名 | {内容第一行}_{时间戳}.doc,后端自动生成,前端无需传入 |
| 清理机制 | 用户主动删除记录时同步删除磁盘文件(硬删除) |
| 配额限制 | tmp/ 总占用不超过磁盘 50%;单用户不超过均分额度(50% ÷ 活跃用户数) |
| 超限行为 | 导出仍然成功,响应附带 warning 字段;同时触发管理员日志告警 |
| 环境变量 | 默认值 | 说明 |
|---|---|---|
TEMP_DIR |
./tmp |
文件存储根目录 |
BASE_URL |
http://192.168.0.200:8000 |
服务对外地址,用于拼接 downloadUrl |
DEFAULT_STYLE_FILE |
./tmp/default.json |
默认样式文件路径;阶段 0 固定使用,阶段 1 作为兜底 |
DISK_QUOTA_RATIO |
0.5 |
tmp/ 目录占磁盘总量的配额比例 |
| 文件 | 职责 |
|---|---|
app/api/v1/export.py |
路由层:导出端点,读取文档、判断是否重新生成、响应封装 |
app/api/v1/export_records.py |
路由层:下载记录列表、重新下载、删除记录 |
app/schemas/export.py |
请求/响应 Pydantic 模型,字段别名(camelCase) |
app/services/export_service.py |
核心:DocxRenderer、load_style_file()、markdown_to_docx_bytes()、文件写入、文件名生成 |
app/services/export_record_service.py |
下载记录 CRUD(写入、查询最新记录、硬删除) |
app/services/storage_monitor.py |
磁盘配额检查、后台定时任务、警告触发逻辑 |
app/services/document_service.py |
提供 get_document() 供导出时读取文档内容 |
app/models/export_record.py |
ExportRecord ORM 模型 |
app/config.py |
temp_dir、base_url、default_style_file、disk_quota_ratio 配置读取 |
app/core/exceptions.py |
ExportError 定义及全局 handler(返回 HTTP 500) |
tmp/default.json |
默认样式文件,阶段 0 固定使用 |
curl -X POST http://192.168.0.200:8000/api/v1/export/doc \
-H "Content-Type: application/json" \
-d '{
"documentId": "doc-abc123",
"styleId": null
}'
预期响应
{
"code": 0,
"message": "Success",
"data": {
"recordId": "rec-abc123",
"downloadUrl": "http://192.168.0.200:8000/api/v1/export/records/rec-abc123/download",
"fileName": "2026年Q2季度报告_1680000000000.doc",
"styleId": "default",
"warning": null
}
}
curl "http://192.168.0.200:8000/api/v1/export/records?userId=user-xyz&page=1&pageSize=20"
curl -O "http://192.168.0.200:8000/api/v1/export/records/rec-abc123/download?userId=user-xyz"
curl -X DELETE "http://192.168.0.200:8000/api/v1/export/records/rec-abc123?userId=user-xyz"
文档版本:v4.0 更新日期:2026-06-24 关联文档:text-editor-backend-api.md · document-management-design.md · text-editor-architecture.md