# 导出 API 设计文档 > 关联模块:`app/api/v1/export.py` · `app/services/export_service.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/files/{filename}` | | 核心依赖 | `python-docx 1.1.2` · `mistune 3.0.2` | | 文件存储 | 服务器本地 `./tmp/` 目录(临时缓存,当前不自动清理) | | 下载链接有效期 | 默认 3600 秒(1 小时),由 `EXPORT_LINK_EXPIRES` 配置 | | 默认样式文件 | `./tmp/default.json`(阶段 0 固定使用;阶段 1 起支持用户选择) | --- ## 2. 端点详情 ### 2.1 导出 .doc 文件 **`POST /api/v1/export/doc`** 用户在编辑器填写文件名、选择格式并确认后触发。后端将 Markdown 内容转换为 `.doc` 文件,存入临时目录,返回下载链接。 #### 请求 **Headers** | Key | Value | |-----|-------| | `Content-Type` | `application/json` | | `Authorization` | `Bearer `(复用宿主应用认证) | **Body** ```json { "fileName": "2026年Q2季度报告", "format": "doc", "content": "# 2026年Q2季度报告\n\n## 一、背景\n\n本季度整体营收同比增长15%。\n\n...", "documentId": "doc-abc123", "styleId": null } ``` | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `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": { "downloadUrl": "http://192.168.0.200:8000/api/v1/files/2026年Q2季度报告_XnjI2ABA1kI.doc", "fileName": "2026年Q2季度报告.doc", "expiresAt": 1749999999000, "styleId": "default" } } ``` | 字段 | 类型 | 说明 | |------|------|------| | `downloadUrl` | string | 临时下载链接,有效期由 `expiresAt` 标注 | | `fileName` | string | 含扩展名的完整文件名 | | `expiresAt` | number | 链接过期时间,Unix 毫秒时间戳 | | `styleId` | string | 实际使用的样式 ID;阶段 0 固定返回 `"default"` | **错误响应** | HTTP 状态码 | code | 触发条件 | |------------|------|---------| | 500 | 500 | Markdown 解析失败 / 文件写入失败 | ```json { "code": 500, "message": "Export failed: Markdown 转换失败: ...", "data": null } ``` --- ### 2.2 下载导出文件 **`GET /api/v1/files/{filename}`** 前端通过 `downloadUrl` 跳转或 `` 触发,后端从临时目录读取文件返回二进制流。此端点不在 Swagger 文档中展示(`include_in_schema=False`)。 #### 请求 ``` GET /api/v1/files/2026年Q2季度报告_XnjI2ABA1kI.doc ``` | 路径参数 | 说明 | |---------|------| | `filename` | 完整文件名(含随机后缀和扩展名) | #### 响应 **200 成功** ``` Content-Type: application/msword Content-Disposition: attachment; filename="2026年Q2季度报告_XnjI2ABA1kI.doc" <二进制文件内容> ``` **错误响应** | HTTP 状态码 | code | 触发条件 | |------------|------|---------| | 500 | 500 | 文件不存在或已被清理 | --- ## 3. 内部处理流程 ``` POST /api/v1/export/doc | v 参数校验(ExportDocRequest) - fileName 最大 255 字符 - format 仅接受 "doc" - content 不为空 | v documentId 存在? | | Yes No | | v | 同步更新草稿记录 | PUT documents/{id} (失败不阻断导出) | | └─────┬─────┘ | v ┌─────────────────────────────┐ │ 样式文件解析 │ │ │ │ styleId 传入且有效? │ │ Yes → 从样式库加载对应 │ │ JSON 文件 │ │ No → 加载默认样式文件 │ │ ./tmp/default.json │ │ │ │ 解析 JSON,提取各样式定义 │ │ (Normal、Heading1-6、 │ │ List Bullet/Number 等) │ └─────────────┬───────────────┘ │ v export_service.export_doc() 1. load_style_definitions(style_file) - 读取样式 JSON(默认或用户指定) - 构建 style_id -> 样式属性 映射表 2. markdown_to_docx_bytes(content, styles) - DocxRenderer 初始化,注入样式映射 └── 应用 Normal 样式的字体和字号 (默认:宋体 12pt) - mistune 解析 Markdown -> AST plugins: table, strikethrough, url - 遍历 token,按映射表应用对应样式 3. _safe_filename(fileName) └── 过滤非法字符,规范化 Unicode 4. 写入 ./tmp/{name}_{token}.doc 5. 生成 downloadUrl + expiresAt | v 返回 ExportDocResponse { downloadUrl, fileName, expiresAt, styleId } ``` ### 样式加载优先级 ``` 请求携带 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/`(由 `TEMP_DIR` 配置) | | 文件命名 | `{safeName}_{randomToken}.doc`,随机后缀防碰撞 | | 当前清理机制 | 无自动清理,文件永久保留 | | `expiresAt` 字段 | 仅作为提示告知前端链接预期有效期,后端不做过期校验 | **待完善(后续迭代):** - 下载端点增加过期时间校验,过期返回 410 - 服务启动时扫描清理过期文件 - 或迁移为数据库缓存(`export_cache` 表,`BYTEA` 存储),支持多实例部署 --- ## 7. 配置项 | 环境变量 | 默认值 | 说明 | |---------|--------|------| | `TEMP_DIR` | `./tmp` | 临时文件存储目录 | | `EXPORT_LINK_EXPIRES` | `3600` | 下载链接有效期(秒) | | `BASE_URL` | `http://192.168.0.200:8000` | 服务对外地址,用于拼接 `downloadUrl` | | `DEFAULT_STYLE_FILE` | `./tmp/default.json` | 默认样式文件路径;阶段 0 固定使用,阶段 1 作为兜底 | --- ## 8. 涉及代码文件 | 文件 | 职责 | |------|------| | `app/api/v1/export.py` | 路由层:参数接收、可选草稿同步、响应封装 | | `app/schemas/export.py` | 请求/响应 Pydantic 模型,字段别名(camelCase) | | `app/services/export_service.py` | 核心:`DocxRenderer`、`load_style_file()`、`markdown_to_docx_bytes()`、文件写入、链接生成 | | `app/services/document_service.py` | 可选路径:`documentId` 存在时调用 `update_document()` | | `app/config.py` | `temp_dir`、`base_url`、`export_link_expires`、`default_style_file` 配置读取 | | `app/core/exceptions.py` | `ExportError` 定义及全局 handler(返回 HTTP 500) | | `tmp/default.json` | 默认样式文件,阶段 0 固定使用 | --- ## 9. 请求示例 ### curl ```bash curl -X POST http://192.168.0.200:8000/api/v1/export/doc \ -H "Content-Type: application/json" \ -d '{ "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 }' ``` > 阶段 0 传 `null` 或不传 `styleId`,后端均使用 `default.json`。 ### 预期响应 ```json { "code": 0, "message": "Success", "data": { "downloadUrl": "http://192.168.0.200:8000/api/v1/files/2026年Q2季度报告_XnjI2ABA1kI.doc", "fileName": "2026年Q2季度报告.doc", "expiresAt": 1749999999000, "styleId": "default" } } ``` ### 下载文件 ```bash # 浏览器直接访问 downloadUrl,或: curl -O "http://192.168.0.200:8000/api/v1/files/2026年Q2季度报告_XnjI2ABA1kI.doc" ``` --- **文档版本**:v2.2 **更新日期**:2026-06-15 **关联文档**:[text-editor-backend-api.md](./text-editor-backend-api.md) · [text-editor-architecture.md](./text-editor-architecture.md) · [environment-setup.md](./environment-setup.md)