关联模块:
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
用户在编辑器填写文件名、选择格式并确认后触发。后端将 Markdown 内容转换为 .doc 文件,按用户和日期分区存储,写入下载记录,返回永久下载链接。
Headers
| Key | Value |
|---|---|
Content-Type |
application/json |
Authorization |
Bearer <token>(复用宿主应用认证) |
Body
{
"userId": "user-xyz",
"fileName": "2026年Q2季度报告",
"format": "doc",
"content": "# 2026年Q2季度报告\n\n## 一、背景\n\n本季度整体营收同比增长15%。\n\n...",
"documentId": "doc-abc123",
"styleId": null
}
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
userId |
string | 是 | 当前用户 ID,用于文件分区存储和配额检查 |
fileName |
string | 是 | 文件名,不含扩展名,最大 255 字符 |
format |
string | 是 | 阶段 0 固定为 "doc" |
content |
string | 是 | 文档当前 Markdown 内容,最大 200KB |
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季度报告_XnjI2ABA1kI.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季度报告_XnjI2ABA1kI.doc",
"styleId": "default",
"warning": "您的存储空间已超出限额,请删除旧文件释放空间"
}
}
| 字段 | 类型 | 说明 |
|---|---|---|
recordId |
string | 下载记录 ID,可用于重新下载或删除 |
downloadUrl |
string | 永久下载链接 |
fileName |
string | 含扩展名的完整文件名 |
styleId |
string | 实际使用的样式 ID;阶段 0 固定返回 "default" |
warning |
string | null | 存储超限时附带警告信息,正常时为 null |
错误响应
| HTTP 状态码 | code | 触发条件 |
|---|---|---|
| 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季度报告_XnjI2ABA1kI.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季度报告_XnjI2ABA1kI.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)
- userId 不为空
- fileName 最大 255 字符
- format 仅接受 "doc"
- content 不为空
|
v
documentId 存在?
| |
Yes No
| |
v |
同步更新草稿记录 |
PUT documents/{id}
(失败不阻断导出)
| |
└─────┬─────┘
|
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. _safe_filename(fileName)
4. 写入 ./tmp/{user_id}/{YYYY-MM-DD}/{name}_{token}.doc
5. 写入 export_records 数据库记录
|
v
storage_monitor.check_after_export(user_id)
检查 tmp/ 总占用 > 磁盘 50%?
→ 是:给所有有文件的用户发警告 + 记录管理员日志
检查该用户目录 > 均分配额?
→ 是:响应附带 warning 字段
|
v
返回 ExportDocResponse
{ recordId, downloadUrl, fileName, styleId, warning }
应用启动
│
├── 后台定时任务(每 30 分钟)
│ │
│ v
│ 扫描 tmp/ 总占用
│ │
│ ├── 超 50%?→ 给所有有文件用户发警告 + 管理员日志
│ │
│ └── 遍历各用户目录
│ │
│ └── 超均分额度?→ 给该用户发警告
│
└── 每次导出后触发一次实时检查(同上逻辑)
请求携带 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 个样式定义
]
}
字段说明见 text-editor-backend-api.md §4 数据结构。
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 -> 样式属性 的映射,加速查询"""
return {s["style_id"]: s for s in style_data["styles"]}
导出时,DocxRenderer 按以下规则从样式映射中读取属性并应用到 python-docx 对象:
| 优先级 | 规则 |
|---|---|
| 1 | 使用样式 JSON 中对应 style_id 的字体和段落格式 |
| 2 | JSON 中字段为 null 时,保留 python-docx 内置 Word 样式默认值 |
| 3 | 标题颜色:JSON 有 color_rgb 时使用,否则强制黑色(覆盖 Word 内置蓝色) |
| 4 | 找不到对应 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) |
所有标题颜色强制设为黑色,覆盖 Word 内置 Heading 样式的蓝色主题色。
| 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}/(按用户、按天分区) |
| 文件命名 | {safeName}_{randomToken}.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 |
可选路径:documentId 存在时调用 update_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 '{
"userId": "user-xyz",
"fileName": "2026年Q2季度报告",
"format": "doc",
"content": "# 2026年Q2季度报告\n\n## 一、背景\n\n本季度整体营收同比增长15%。\n\n## 二、核心数据\n\n### 2.1 收入\n\n| 月份 | 收入(万元) |\n|------|------|\n| 4月 | 120 |\n| 5月 | 135 |\n\n### 2.2 用户增长\n\n- 新增用户:12,000\n- 月活用户:85,000\n\n## 三、下季度计划\n\n1. 推进产品 A 上线\n2. 扩充销售团队\n",
"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季度报告_XnjI2ABA1kI.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"
文档版本:v3.0 更新日期:2026-06-17 关联文档:text-editor-backend-api.md · text-editor-architecture.md · environment-setup.md