# 导出 API 设计文档 > 关联模块:`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](./text-editor-backend-api.md) § 3. 导出 API(阶段 0) --- ## 1. 概览 | 项目 | 内容 | |------|------| | 功能 | 将编辑器中的 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 分钟检查一次;每次导出后也触发实时检查 | --- ## 2. 端点详情 ### 2.1 导出 .doc 文件 **`POST /api/v1/export/doc`** 用户在编辑器填写文件名、选择格式并确认后触发。后端将 Markdown 内容转换为 `.doc` 文件,按用户和日期分区存储,写入下载记录,返回永久下载链接。 #### 请求 **Headers** | Key | Value | |-----|-------| | `Content-Type` | `application/json` | | `Authorization` | `Bearer `(复用宿主应用认证) | **Body** ```json { "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 成功(正常)** ```json { "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 成功(存储超限)** ```json { "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 解析失败 / 文件写入失败 | --- ### 2.2 获取下载记录列表 **`GET /api/v1/export/records`** 分页查询当前用户的全部导出记录。 #### 请求 | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | `userId` | string | 是 | 当前用户 ID(Query 参数) | | `page` | integer | 否 | 页码,默认 1 | | `pageSize` | integer | 否 | 每页数量,默认 20,最大 100 | | `sortOrder` | string | 否 | `asc` / `desc`,默认 `desc` | #### 响应 ```json { "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 } } } ``` --- ### 2.3 重新下载文件 **`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 不匹配 | --- ### 2.4 删除下载记录 **`DELETE /api/v1/export/records/{recordId}`** 同步删除磁盘文件和数据库记录(硬删除)。后端查询时同时校验 `recordId + userId`,查不到一律返回 404。 #### 请求 | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | `recordId` | string | 是 | 路径参数 | | `userId` | string | 是 | 当前用户 ID(Query 参数) | #### 响应 ```json { "code": 0, "message": "Record deleted successfully" } ``` **错误响应** | HTTP 状态码 | code | 触发条件 | |------------|------|---------| | 404 | 4041 | 记录不存在,或 recordId 与 userId 不匹配 | --- ### 2.5 管理端存储查询 **`GET /api/v1/admin/storage`** 查看 `tmp/` 目录总占用、各用户占用及配额情况。 #### 响应 ```json { "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` | 该用户是否超过均分配额 | --- ## 3. 内部处理流程 ### 3.1 导出流程 ``` 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 } ``` ### 3.2 磁盘监控流程 ``` 应用启动 │ ├── 后台定时任务(每 30 分钟) │ │ │ v │ 扫描 tmp/ 总占用 │ │ │ ├── 超 50%?→ 给所有有文件用户发警告 + 管理员日志 │ │ │ └── 遍历各用户目录 │ │ │ └── 超均分额度?→ 给该用户发警告 │ └── 每次导出后触发一次实时检查(同上逻辑) ``` ### 3.3 样式加载优先级 ``` 请求携带 styleId(阶段 1) └─ styleId 有效 → 使用用户样式库中对应的 JSON 文件 └─ styleId 无效 → 返回 404,不降级 请求未携带 styleId(阶段 0 / 默认) └─ 加载 ./tmp/default.json(由 DEFAULT_STYLE_FILE 配置) └─ default.json 不存在 → 返回 500 ``` --- ## 4. 默认样式文件(`default.json`) ### 4.1 文件位置与用途 | 项目 | 内容 | |------|------| | 文件路径 | `./tmp/default.json`(由 `DEFAULT_STYLE_FILE` 配置) | | 格式 | 样式 JSON,与用户上传文档提取的格式完全相同 | | 来源 | 从内置标准 Word 文档提取,随服务部署一同发布 | | 阶段 0 行为 | 所有导出请求固定使用此文件,不支持切换 | | 阶段 1 行为 | 用户未传 `styleId` 时作为兜底;用户可上传文档生成新样式并选用 | ### 4.2 文件结构 ```json { "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 数据结构](./text-editor-backend-api.md)。 ### 4.3 后端加载逻辑(伪代码) ```python 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"]} ``` ### 4.4 样式应用规则 导出时,`DocxRenderer` 按以下规则从样式映射中读取属性并应用到 `python-docx` 对象: | 优先级 | 规则 | |--------|------| | 1 | 使用样式 JSON 中对应 `style_id` 的字体和段落格式 | | 2 | JSON 中字段为 `null` 时,保留 `python-docx` 内置 Word 样式默认值 | | 3 | 标题颜色:JSON 有 `color_rgb` 时使用,否则强制黑色(覆盖 Word 内置蓝色) | | 4 | 找不到对应 `style_id` 时,回退到 `Normal` 样式 | --- ## 5. Markdown 与 Word 样式映射 ### 5.1 标题 | 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 样式的蓝色主题色。 ### 5.2 内联样式 | Markdown | 语义 | python-docx 处理 | |----------|------|-----------------| | `**文字**` | 粗体 | `run.bold = True` | | `*文字*` / `_文字_` | 斜体 | `run.italic = True` | | `~~文字~~` | 删除线 | `run.font.strike = True` | | `` `代码` `` | 行内代码 | `run.font.name = "Courier New"` | ### 5.3 列表 | 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")` | ### 5.4 块级元素 | Markdown | Word 样式 | python-docx 处理 | |----------|-----------|-----------------| | `> 文字` | Quote | `doc.add_paragraph(style="Quote")` | | ` ```代码块``` ` | No Spacing | 字体 Courier New 10pt,颜色 `#333333` | | `---` / `***` | 段落底部边框 | OxmlElement 注入 `w:pBdr` | | `\| 列 \| 列 \|` | Table Grid | `doc.add_table()`,表头行自动加粗 | ### 5.5 表格 AST 结构说明 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` 两层结构。 --- ## 6. 文件存储说明 | 项目 | 内容 | |------|------| | 存储路径 | `./tmp/{user_id}/{YYYY-MM-DD}/`(按用户、按天分区) | | 文件命名 | `{safeName}_{randomToken}.doc`,随机后缀防碰撞 | | 清理机制 | 用户主动删除记录时同步删除磁盘文件(硬删除) | | 配额限制 | `tmp/` 总占用不超过磁盘 50%;单用户不超过均分额度(50% ÷ 活跃用户数) | | 超限行为 | 导出仍然成功,响应附带 `warning` 字段;同时触发用户和管理员通知 | --- ## 7. 配置项 | 环境变量 | 默认值 | 说明 | |---------|--------|------| | `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/` 目录占磁盘总量的配额比例 | --- ## 8. 涉及代码文件 | 文件 | 职责 | |------|------| | `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 固定使用 | --- ## 9. 请求示例 ### 导出文件 ```bash 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 }' ``` **预期响应** ```json { "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 } } ``` ### 获取下载记录列表 ```bash curl "http://192.168.0.200:8000/api/v1/export/records?userId=user-xyz&page=1&pageSize=20" ``` ### 重新下载 ```bash curl -O "http://192.168.0.200:8000/api/v1/export/records/rec-abc123/download?userId=user-xyz" ``` ### 删除记录 ```bash 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-backend-api.md) · [text-editor-architecture.md](./text-editor-architecture.md) · [environment-setup.md](./environment-setup.md)