# M5 导出与下载从 Markdown 到 PDF：为什么先做最小导出闭环

到 `M5` 为止，这个项目已经具备了几个关键前提：

- 有标准双语简历内容
- 有草稿 / 已发布边界
- 公开站只读取已发布内容
- 后台与 AI 链路也开始围绕同一份稳定数据工作

这时很自然会出现一个问题：

既然内容已经能展示了，为什么还要专门拉一个里程碑来做导出？

答案很简单，因为“能展示”不等于“能交付”。对于简历项目来说，真正要让它成为一个完整产品，最终一定要面对下载、分享和对外提交这些场景。而一旦进入这些场景，导出能力就不再是附属功能，而是内容闭环的一部分。

这篇文章就围绕 `M5` 讲清楚三件事：

1. 为什么导出能力值得单独成为一个里程碑
2. 为什么要先做 Markdown，再做 PDF
3. 为什么导出必须绑定已发布内容，并由 `web / admin` 共享同一套服务端出口

## 一、为什么 `M5` 要单独拉出来做

很多项目在做到内容展示后，会顺手在前端加一个“导出 PDF”按钮，好像这样就算把导出做了。但对教程型项目来说，这种做法很容易把关键边界做模糊。

因为导出真正涉及的不是一个按钮，而是一整条新的产品链路：

- 导出的内容到底来自草稿还是已发布版本
- 双语导出怎么切换
- Markdown 和 PDF 的内容一致性如何保证
- 前端入口是自己拼内容，还是复用服务端接口

如果这些问题不单独拿出来收口，后面很容易出现这些混乱：

- 公开站和导出内容不一致
- Markdown 和 PDF 各自维护一套文本来源
- 后台入口开始暗示“能导出草稿”
- 前端自己拼内容，服务端只剩一个空壳下载接口

所以 `M5` 的目标不是单纯多做两种格式，而是把“对外输出能力”正式纳入系统主线。

## 二、`M5` 这轮到底要交付什么

这一轮的交付目标可以概括成一句话：

让读者能看到一份已发布简历如何通过统一服务端出口，先被导出为 Markdown，再导出为 PDF，最后被 `web / admin` 两端以清晰语义接入。

这里面有四个很重要的边界。

### 1. 先绑定已发布内容，而不是草稿

导出最容易摇摆的地方，就是到底该基于后台草稿还是基于公开已发布版本。

`M5` 在这个问题上选择得非常明确：只导出已发布内容。

这样做的好处是：

- 公开展示与下载内容保持一致
- 草稿不会被误当成对外输出结果
- 后续 AI、导出、SEO 都继续围绕同一份稳定快照工作

对于教程型项目来说，这也是一条非常重要的行为边界，因为它能帮助读者真正理解“已发布快照”不是多余设计，而是对外输出的稳定基础。

### 2. 先做 Markdown，再做 PDF

Markdown 和 PDF 看起来都属于导出，但它们在教学节奏里的角色不同。

Markdown 更适合作为第一步，因为它：

- 内容结构直观
- 容易调试
- 容易验证双语和模块顺序
- 更适合先锁定“导出内容本身是什么”

PDF 更适合作为第二步，因为它：

- 更接近真实下载与交付场景
- 可以承接最终文件输出体验
- 但一开始如果做太重，很容易把重点从“导出链路”拉向“排版样式”

所以 `M5` 的顺序不是随意安排，而是在刻意降低复杂度。

### 3. PDF 复用 Markdown 的内容来源

一旦项目同时支持多种导出格式，就会立刻遇到一个问题：两种格式要不要共用同一份内容来源？

`M5` 的选择是先让 PDF 复用 Markdown 导出的文本来源，再做 PDF 渲染。这样做的价值很大：

- Markdown 和 PDF 的内容一致性更容易保证
- 后续修正文案或模块顺序时，只需要先修文本结构
- PDF 仍然可以在后续阶段继续独立优化样式，而不破坏当前主线

换句话说，`M5` 先把“内容一致”放在“视觉精细”之前。

### 4. `web` 与 `admin` 都有入口，但语义不能混淆

这一步很关键。

表面上看，`web` 和 `admin` 都只是放一个下载链接，但它们的语义其实不同：

- `web`：面向公开站访问者，直接下载已发布版本
- `admin`：面向后台用户，强调“这里下载的仍然是已发布版本”，而不是后台草稿

如果这一层语义不说清楚，读者很容易误解成“后台入口 = 草稿导出入口”，后面就会把发布态和导出边界搅在一起。

## 三、`M5` 为什么要按这 3 个 issue 来拆

这一轮的拆法其实是在回答一个很实际的问题：

当我们要把“可展示内容”变成“可下载交付内容”时，应该先稳定什么？

`M5` 的答案是：

先稳定文本导出，再稳定文件导出，最后再稳定用户入口。

### issue-17：先做 Markdown 导出

第一步不是直接做 PDF，而是先把 Markdown 导出站稳。

这一步真正固定下来的是：

- 导出内容来自已发布快照
- 双语导出怎么切换
- 导出响应头和下载文件名如何定义
- 导出文本结构有哪些核心模块

这让项目第一次有了一个“内容可交付”的最小出口，而且这个出口足够透明、足够容易验证。

### issue-18：再做 PDF 导出

Markdown 站稳之后，才进入 PDF。

这一步要解决的是：

- 服务端如何输出最小可用 PDF
- PDF 如何继续绑定已发布内容
- PDF 如何尽量复用 Markdown 的文本结构

这里刻意不做复杂模板，是为了让读者把注意力继续放在“导出链路成立了没有”，而不是陷进排版系统的复杂度里。

### issue-19：最后接 `web / admin` 下载入口

当服务端 Markdown / PDF 两条导出链路都成立后，最后才轮到前端入口。

这一步主要验证的是：

- `web` 是否能基于当前语言状态生成正确下载链接
- `admin` 是否能提供清晰的后台下载入口说明
- `viewer / admin` 是否能在后台看见不同语义的说明文案

这一步的重点不是新增能力，而是把已经成立的服务端导出能力正确地表达给用户。

## 四、这一轮最重要的设计取舍

### 1. 为什么导出必须只读已发布版本

如果导出读取的是草稿，整个系统就会立刻出现两个版本真相：

- 公开站读一份
- 下载又读另一份

这样后面几乎每条链路都会变得不稳定：

- 用户在网页上看到的内容和下载下来的内容不一样
- PDF / Markdown 与公开站无法对齐
- 管理员可能误把未发布内容对外发送

所以 `M5` 坚持只导出已发布快照，本质上是在保护整个内容系统的单一输出源。

### 2. 为什么 Markdown 比 PDF 更适合作为第一步

Markdown 的优势不在于“更高级”，而在于它非常适合做内容一致性的第一道验证：

- 文本结构清晰
- 调试成本低
- 响应头和文件名约定容易测试
- 双语切换更容易直接看出差异

对于教程型项目，这种低复杂度、高透明度的能力非常适合作为导出链路的第一步。

### 3. 为什么 PDF 先做最小可用，而不是直接做复杂模板

PDF 很容易把人带进另一个世界：排版、分页、布局、视觉、打印适配、多模板。

这些能力当然都重要，但如果在 `M5` 一开始就进入这些内容，读者就很难继续抓住“导出主线”。

所以这一步的策略是：

- 先打通 PDF 下载链路
- 先让内容可用
- 先让响应契约稳定

等这些成立后，复杂模板系统再放到后续阶段，就不会冲掉当前里程碑的重点。

### 4. 为什么前端入口必须复用服务端导出接口

如果前端自己拼 Markdown 或 PDF 内容，看起来也许能更快做出按钮，但这会让导出能力重新分裂：

- 服务端一套内容来源
- 前端又一套内容拼装逻辑

这和我们一直在避免的“逻辑分散”没有区别。

所以 `M5` 继续坚持同一个原则：

- `NestJS` 负责导出业务能力
- `web / admin` 只负责暴露入口和承接下载体验

这样后续无论扩模板、加权限还是做部署，系统边界都不会被打散。

## 五、关键文件应该怎么读

如果你想顺着代码复盘 `M5`，建议优先看这几组文件。

### Markdown 导出

- `apps/server/src/modules/resume/resume-markdown-export.service.ts`
- `apps/server/test/resume-publication.e2e-spec.ts`

这里解决的是“已发布简历如何先变成稳定的文本导出”。

### PDF 导出

- `apps/server/src/modules/resume/resume-pdf-export.service.ts`
- `apps/server/src/modules/resume/resume.controller.ts`

这里解决的是“如何在服务端把同一份已发布内容继续渲染成 PDF”。

### 双端下载入口

- `apps/web/components/published-resume-shell.tsx`
- `apps/admin/components/export-entry-panel.tsx`

这里解决的是“同一套导出能力如何在公开站和后台端被正确表达”。

## 六、`M5` 的测试重点是什么

`M5` 的测试重点不是“文件看起来多漂亮”，而是导出链路的契约和边界有没有被锁稳。

### 1. 先锁 Markdown 导出契约

这里至少要证明：

- 未发布时导出返回 `404`
- 发布后可以获取 Markdown
- `Content-Type` 是 `text/markdown`
- 附件文件名和 `locale` 参数正确

这一步锁住的是“导出内容和已发布状态之间的关系”。

### 2. 再锁 PDF 导出契约

PDF 这一层要证明：

- 发布后可以下载 PDF
- `Content-Type` 是 `application/pdf`
- 返回内容真的是 PDF 二进制
- 文件名和语言切换行为正确

这一步锁住的是“PDF 作为第二种导出格式的最小可用性”。

### 3. 最后锁双端入口行为

前端这一层要证明：

- `web` 在不同语言状态下能生成正确导出链接
- `admin` 能看见 Markdown / PDF 入口
- `viewer` 和 `admin` 在后台看到的说明语义不同

也就是说，前端测试重点是“入口行为和语义”，不是导出内容本身。

## 七、这一轮最容易踩的坑

### 1. 把导出直接绑定到草稿

这样短期看起来更方便，但后面一定会让公开站、导出和后台状态彼此不一致。

### 2. Markdown 和 PDF 各自维护一套内容来源

一旦这么做，后面内容顺序、模块或双语字段稍微一改，两套导出就会开始漂移。

### 3. 一开始就把 PDF 做成复杂模板工程

这样很容易让 `M5` 从“导出链路”变成“排版系统”，教程重心会彻底跑偏。

### 4. 让后台入口暗示“可以导出草稿”

这会直接破坏 `M3` 建立起来的已发布边界，也会让读者误解后台下载的真实语义。

## 八、为什么 `M5` 完成后，`M6` 会更顺

`M5` 的成果不只是多了两个下载链接，而是让系统第一次拥有了稳定的“对外文件输出”能力。

一旦这一层成立，后面的质量与部署工作就会自然很多：

- `M6` 可以把导出链路纳入类型检查、测试和 CI 质量门禁
- 部署时可以更明确地验证公开下载接口是否稳定
- 后续扩多模板、复杂 PDF 或导出历史时，也能站在已有出口之上继续增强

换句话说，`M5` 不是在补一个附属功能，而是在让系统的“输出能力”第一次完整起来。

## 九、如果你要自己复现 `M5`，建议按这个顺序

1. 先做 Markdown 导出，锁定内容结构和已发布边界
2. 再做 PDF 导出，复用已有文本来源
3. 最后再把 `web / admin` 下载入口接出来

这个顺序的好处是，系统每往前走一步，都在先稳定一层更基础的东西：

- 内容结构
- 文件输出
- 用户入口

这样不会一开始就把所有复杂度堆到前端按钮上。

## 十、这篇文章对应的原始材料

如果你想继续往下看每个 issue 的实现细节，可以配合这些开发日志一起阅读：

- `docs/30-开发日志/M5-issue-17-Markdown-导出.md`
- `docs/30-开发日志/M5-issue-18-PDF-导出.md`
- `docs/30-开发日志/M5-issue-19-web-admin-下载入口.md`
- `docs/40-教程与博客/M5-导出与下载从-Markdown-到-PDF-教程大纲.md`

这些记录更适合看“每一步具体做了什么”，而本文更希望回答“为什么导出能力要先这样拆”。

## 小结

`M5` 做的不是一个完整导出中心，而是先把导出能力最关键的四个边界收住：

- 只导出已发布内容
- 先稳定 Markdown，再补 PDF
- 让两种格式尽量共享同一份内容来源
- 让 `web / admin` 共享同一套服务端出口，但保持清晰语义

这些边界一旦成立，后面的复杂 PDF 模板、多模板导出、导出历史和部署验证，都会更像是在已有链路上继续增强，而不是重新发明一套输出系统。
