# 简历版本管理与导出模块接口说明 > 职来 (mydreamjob) · 简历版本管理与导出基础模块 > 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer ` ## 1. 概述 学生在既有单简历(`/profile/me/resume`,上传即替换)之外,新增**多版本简历**管理: 创建/列表/详情/修改/删除/设默认,以及**异步导出任务**(复制该版本文件到导出目录并查询状态)。 本轮**不做** AI 简历生成/优化、简历查重、词云、匹配算法、SHAP、Offer 新功能。 与旧单简历 `student_profiles.resume_url` **完全独立**,不修改其原有语义。 ## 2. 数据模型(Flyway V14 / V15) **`resume_versions`**(简历版本) - `id`、`user_id`(FK→users)、`name`(版本名,可空)、`version_no`(每用户递增:max+1) - `file_storage_path`/`file_name`/`content_type`/`file_size`(复用 `ResumeStorageService` 存储) - `default_key`:可空判别列,默认时 `'DEFAULT'` 否则 `NULL`;`UNIQUE(user_id, default_key)` 保证**每用户最多一个默认**(多个 NULL 不冲突) - `status`(ACTIVE/DELETED)、`created_at/updated_at` - `UNIQUE(user_id, version_no)`:并发创建时版本号唯一(DB 硬约束,Service 层独立事务重试) **`resume_export_tasks`**(导出任务) - `id`、`user_id`(FK→users)、`resume_version_id`(FK→resume_versions **ON DELETE SET NULL**) - `resume_version_name`(版本名快照,删除版本后任务仍可读) - **源文件快照**(V15 新增,创建任务时写入,worker 据此读取而非查当前版本): `source_storage_path`、`source_file_name`、`source_content_type`、`source_file_size` - `status`:`PENDING → RUNNING → SUCCESS / FAILED` - `output_path`/`output_name`(成功时,`output_path` 仅内部使用**不对外返回**)、`error_message`(**失败必须记录原因**) - `created_at/started_at/finished_at` ## 2.1 文件一致性保证 - **修改版本文件**:先校验并保存新文件 → 更新数据库 → 事务提交后再删除旧文件;数据库保存失败时清理刚保存的新文件,旧文件保持可用;若旧文件正被进行中的导出任务引用(快照),则不删除以保导出一致性。 - **删除版本**:先完成数据库删除,事务提交后再删除存储文件;数据库失败时不得删除原文件。 - **创建版本**:版本号并发重试最终失败或数据库写入失败时,清理已保存但未被引用的新文件,不留下孤儿文件。 ## 3. 接口清单 ### 3.1 创建简历版本 ``` POST /profile/me/resume-versions (multipart/form-data) ``` 权限:**学生本人**。 表单字段: | 字段 | 必填 | 约束 | |---|---|---| | `file` | 是 | PDF/DOC/DOCX,≤10MB;扩展名、Content-Type 与实际文件结构必须一致 | | `name` | 否 | ≤120 字符,可空 | 默认状态 `ACTIVE`、**非默认**;版本号自动分配(`max+1`)。并发创建由 DB 唯一约束兜底,冲突自动重试(独立事务)。 ### 3.2 简历版本列表 ``` GET /profile/me/resume-versions ``` 权限:学生本人。返回本人 ACTIVE 版本,`version_no` 倒序。 响应**不包含** `userId`、`filePath` 等内部字段: ```json { "code": 200, "data": [ { "id": 1, "name": "秋招版", "versionNo": 2, "fileName": "resume.pdf", "contentType": "application/pdf", "fileSize": 1048576, "default": false, "status": "ACTIVE", "createdAt": "...", "updatedAt": "..." } ] } ``` ### 3.3 简历版本详情 ``` GET /profile/me/resume-versions/{id} ``` 权限:学生本人。他人 403(`FORBIDDEN`),不存在/已删除 404(`RESUME_VERSION_NOT_FOUND`)。 ### 3.4 修改简历版本 ``` PUT /profile/me/resume-versions/{id} (multipart/form-data) ``` 权限:学生本人。可选字段(均不传则不更新): - `file`:替换文件(校验同上,替换时删除旧文件); - `name`:重命名。 ### 3.5 删除简历版本 ``` DELETE /profile/me/resume-versions/{id} ``` 权限:学生本人。 - **默认版本不可删除**:必须先设置其他默认(409 `DEFAULT_RESUME_DELETE_NOT_ALLOWED`); - **有 PENDING/RUNNING 导出任务不可删除**(409 `RESUME_EXPORT_IN_PROGRESS`); - 其余:删除存储文件并移除记录(硬删除)。 ### 3.6 设为默认简历 ``` POST /profile/me/resume-versions/{id}/default ``` 权限:学生本人。同事务内清除旧默认并设置新默认;已默认幂等;并发冲突 409 `DUPLICATE_DEFAULT_RESUME`。 同一用户最多一个默认(DB 唯一约束 `uk_resume_versions_default`)。 ### 3.7 创建导出任务 ``` POST /profile/me/resume-versions/{id}/export ``` 权限:学生本人。创建 `PENDING` 任务,并将当前版本的**源文件快照**(`source_storage_path/file_name/content_type/file_size`)写入任务;事务提交后触发 `@Async` worker: 读取任务快照中的源文件 → 复制到独立导出目录 `./resume-exports/{userId}/{taskId}{ext}` → `SUCCESS`; 源文件缺失/IO 异常 → `FAILED` + `error_message`。返回任务视图。 > 由于 worker 从任务快照读取源文件,任务创建后即使立刻修改/替换简历版本,导出结果仍为创建任务时的原文件。 ### 3.8 导出任务状态 ``` GET /resume-export-tasks/{taskId} ``` 权限:学生本人(task.userId = 当前用户,否则 403;不存在 404)。 响应**不包含** `userId`、`outputPath`、`source_*` 等内部字段: ```json { "code": 200, "data": { "id": 10, "resumeVersionId": 1, "resumeVersionName": "秋招版", "status": "SUCCESS", "outputName": "resume.pdf", "downloadAvailable": true, "errorMessage": null, "createdAt": "...", "startedAt": "...", "finishedAt": "..." } } ``` ### 3.9 下载导出产物 ``` GET /resume-export-tasks/{taskId}/download ``` 权限:学生本人。 - 仅任务创建者本人且任务状态为 `SUCCESS` 可下载; - 其他用户 403(`FORBIDDEN`);任务不存在 404(`RESUME_EXPORT_TASK_NOT_FOUND`);未完成/失败 409(`RESUME_EXPORT_NOT_READY`); - 使用 `Content-Disposition: attachment`,文件名取任务保存的 `outputName`,Content-Type 取 `source_content_type`; - 不返回任何 Windows/Linux 绝对路径、`file_storage_path`、`output_path`。 ## 4. 权限矩阵 | 操作 | 本人(学生) | 其他学生 | 企业 | 未登录 | |---|---|---|---|---| | 版本 增删改查 / 设默认 / 创建导出 | ✅ | 403 | 403 | 401 | | 导出任务查询 / 下载 | ✅(task.userId=本人) | 403 | 403 | 401 | 安全说明: - 身份一律来自 JWT(`auth.getName()` = userId),**不信任请求体/路径中的 userId**; - 不能读取其他用户的简历(owner 校验); - 不返回内部堆栈;错误响应不泄露数据库结构。 - 上传文件名拒绝路径分隔符、控制字符和响应头换行;下载历史文件时再次净化文件名。 - PDF 校验文件头和结尾标记;DOC 校验 OLE/Word 文档结构标记;DOCX 校验 OOXML 必需条目,并限制 ZIP 条目数、解压总量和压缩比,拒绝路径穿越及压缩炸弹。 - 文件替换采用“新文件落盘 → 数据库更新 → 事务提交后删除旧文件”;回滚时清理新文件。底层本地存储先写 `.uploading` 临时文件,再原子移动到最终随机存储名,失败不暴露半文件。 - 上述措施属于格式与资源安全校验,**不等同于病毒查杀或沙箱检测**;生产部署仍应在存储接入层配置恶意文件扫描。 ## 5. 异步导出设计 - 新增 `AsyncConfig`:`@EnableAsync` + 专用线程池 `resumeExportExecutor`(core 2 / max 4 / queue 100,可配 `app.async.resume-export.*`)。 - `ResumeExportService.createExportTask`:校验本人+版本 → 保存 `PENDING` → 通过 `TransactionSynchronizationManager` **afterCommit** 触发 `ResumeExportWorker.execute(taskId)`(保证 worker 在任务行提交后才读得到)。 - `ResumeExportWorker.execute`:`@Async + @Transactional`。`RUNNING` → **从任务快照读取源文件** → 复制到导出目录 → `SUCCESS`;任何异常 → `FAILED` + 原因;finally 置 `finished_at`。所有异常内部捕获,不产生未处理异步异常。**不按 resumeVersionId 查当前版本文件**。 - 导出目录:`app.resume.export-dir`(默认 `./resume-exports`),与简历原始存储 `./resumes` 分离。 ## 6. 并发控制 - **每用户最多一个默认**:`UNIQUE(user_id, default_key)` DB 硬约束 + Service 事务内"清旧设新"。 - **版本号不重复**:`UNIQUE(user_id, version_no)` DB 硬约束;`ResumeVersionWriter` 用 `REQUIRES_NEW` 独立事务插入(唯一键冲突会标记事务 rollback-only,独立事务保证单次冲突只回滚自身,外层短退避后重试)。 - 并发测试:两版本并发设默认 → 最终恰一个默认;并发创建 → 版本号不重复。 ## 7. 数据库迁移说明(V14) 文件:`src/main/resources/db/migration/V14__resume_versions.sql`(兼容 MySQL 8 / H2 MODE=MySQL)。 - `resume_versions`:如上,含 FK(user)、`uk_resume_versions_user_no`、`uk_resume_versions_default`、索引 `(user_id, created_at)`、`(status)`。 - `resume_export_tasks`:FK(user)、FK(version) **ON DELETE SET NULL**(删除版本不破坏任务历史)、索引 `(user_id, created_at)`、`(status)`、`(resume_version_id)`。 ## 8. Apifox 测试步骤 前置:启动基础设施 → 启动应用 → 导入 `docs/openapi-rc.json`(或 `GET /api/v1/v3/api-docs`)。 1. 登录学生账号(Mock 验证码:日志不再输出,本地按 `docs/frontend-integration.md` §3 的受控方式取得;生产禁止查表)。 2. `POST /profile/me/resume-versions`(multipart:`file` + `name=秋招版`)→ 200,`versionNo=1`。 3. 再次上传 → `versionNo=2`;设置 `name=社招版`。 4. `GET /profile/me/resume-versions` → 列表含两个版本。 5. `POST /profile/me/resume-versions/{id}/default` → `default=true`;再次设置另一个 → 默认切换。 6. `DELETE` 默认版本 → 409;先设其他默认后删除 → 200。 7. `POST /profile/me/resume-versions/{id}/export` → 200 `PENDING`;`GET /resume-export-tasks/{taskId}` 轮询至 `SUCCESS`;`GET /resume-export-tasks/{taskId}/download` 下载导出文件。 8. 越权:另一学生 token 访问详情/导出任务/下载 → 403;无 token → 401;未完成任务下载 → 409。 ## 9. 已知限制 1. **导出产物为原文件副本**:不解析/渲染 PDF/DOC 内容(无渲染依赖),`outputName` 为原文件名。 2. **版本号冲突重试有上限**:极端并发(3 次重试仍冲突)返回 400,客户端可重试;DB 唯一约束保证不会产生重复版本号。 3. **`version_no` 不回收**:删除版本不回收编号(不影响功能)。 4. **默认简历仅作用于版本体系**:不联动旧 `student_profiles.resume_url`(按确认保持独立)。 5. **被导出任务引用的旧文件可能延迟清理**:替换版本文件时,若旧文件正被 PENDING/RUNNING 导出任务引用则保留(保导出一致性);任务结束后这些文件不会自动回收,需后续清理策略。 6. **OpenAPI 快照**:`docs/openapi-rc.json` 由 H2 集成测试上下文导出(**49 路径**,server URL 为测试默认值),接入现网后可用 `GET /api/v1/v3/api-docs` 重新导出。 7. **不包含恶意软件扫描**:当前只执行文件格式、OOXML 结构、路径和资源消耗防护;不对宏、嵌入对象或恶意载荷作杀毒结论。 ## 10. 错误码(新增 82xx) | 错误码 | 枚举 | HTTP | 含义 | |---|---|---|---| | 8201 | `RESUME_VERSION_NOT_FOUND` | 404 | 简历版本不存在/已删除 | | 8202 | `DEFAULT_RESUME_DELETE_NOT_ALLOWED` | 409 | 默认简历删除前需先设其他默认 | | 8203 | `RESUME_EXPORT_TASK_NOT_FOUND` | 404 | 导出任务不存在 | | 8204 | `RESUME_EXPORT_IN_PROGRESS` | 409 | 导出任务进行中,不可删除版本 | | 8205 | `DUPLICATE_DEFAULT_RESUME` | 409 | 并发设置默认冲突 | | 8206 | `RESUME_EXPORT_NOT_READY` | 409 | 导出任务未完成,不可下载 | ## S03-B:企业下载候选人简历 ### `GET /company/candidates/{applicationId}/resume` 新增的**唯一**企业侧简历读取入口。返回文件流,**不返回** `resumeUrl`、存储相对/绝对路径、 学生用户 ID、手机号或邮箱。 - 认证:`Authorization: Bearer ` - 路径参数:`applicationId`(本企业岗位下的投递) - 响应:二进制文件流;`Content-Type` 只取自上传时已校验的白名单 (`application/pdf`、`application/msword`、DOCX),否则回退 `application/octet-stream` - 响应头:`Content-Disposition: attachment; filename*=UTF-8''<已净化文件名>`, 以及 `Cache-Control: no-store`(私有简历不进任何中间缓存) - 文件名继续经过 `ResumeFilenameSanitizer`,拒绝路径分隔符、控制字符与响应头换行 ### 错误语义 | 场景 | 结果 | |---|---| | 未登录 / 无有效会话 | 401 | | 非 COMPANY 角色(STUDENT / ADMIN) | 403 | | 当前用户没有企业资料 | 403 | | 投递不存在 | 404 `APPLICATION_NOT_FOUND` | | 跨企业投递 | 403 | | 隐私设置不允许(含 PRIVATE / 无设置) | 403 `FORBIDDEN` | | 授权通过但学生没有简历 | 404 `PROFILE_NOT_FOUND` | ### 授权与副作用 - 授权顺序:当前 COMPANY 用户 → 当前 Company → Application+Job → `Job.companyId` → Student → privacy setting → profile/file。归属信息全部由服务端推导, 不接受客户端传入的 `companyId`/`studentUserId`。 - **拒绝路径不会调用存储读取**(测试以计数包装器验证为零次)。 - 该接口只读:不修改 `student_profiles`、`resume_versions`、投递、评分、排名、推荐、邀约、 消息、通知或风险表;不发送 RabbitMQ、短信或邮件。 - 学生本人的上传、下载、版本与导出接口语义完全不变。 - 企业候选人列表/详情 DTO **仍不含** `resumeUrl`。 - 不新增按 `studentUserId` 或 `candidateKey` 下载简历的入口;人才发现本轮不得直接下载简历。 ## 工作经历优化模拟接口(AI-001,2026-10-22) 新增 POST `/api/v1/profile/me/work-experience-optimization`,仅学生本人登录可调用,默认关闭。设置 `ai.work-experience.stub-enabled=true` 后用于合成数据联调,返回 `simulated=true`;不调用外部 AI、不保存工作经历。真实 AI 优化仍未实现。详见工作区根目录 `docs/AI-001-工作经历AI优化-前端对接方案.md`。 ### AI-001 v0.6开发真实模式 mode配置off/stub/deepseek,默认off,兼容旧stub-enabled。deepseek仅dev且Key完整时启动,返回simulated=false、promptVersion=work-experience-deepseek-v1;stub仍true。新增模型安全错误400/502/503/504,不保存内容;仅隔离开发合成数据联调,未生产验收。