Browse Source

feat: 完善在线简历资料保存与导出接口

fix/mysql57-module-migrations-20261009
Zhang Shun 7 days ago
parent
commit
216267c628
  1. 104
      docs/online-resume-frontend-handover-20261020.md
  2. 36
      docs/online-resume-r01e-query-budget-20261020.md
  3. 788
      docs/openapi-rc.json
  4. 22
      docs/verify_openapi.py
  5. 2
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  6. 2
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java
  7. 4
      mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java
  8. 2
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java
  9. 16
      mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResume.java
  10. 95
      mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeContent.java
  11. 97
      mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeController.java
  12. 70
      mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeDocxWriter.java
  13. 29
      mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeOpenApiConfiguration.java
  14. 5
      mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeRepository.java
  15. 7
      mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeSaveRequest.java
  16. 233
      mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeService.java
  17. 10
      mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeView.java
  18. 8
      mydreamjob-server/src/main/resources/db/migration/V59__online_resume.sql
  19. 6
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationMigrationTest.java
  20. 4
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  21. 8
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java
  22. 2
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java
  23. 3
      mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java
  24. 8
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java
  25. 6
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java
  26. 10
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java
  27. 47
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java
  28. 1
      mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataCollectorQueryBudgetTest.java
  29. 4
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java
  30. 4
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java
  31. 232
      mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeIntegrationTest.java
  32. 50
      mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeMigrationTest.java
  33. 25
      mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeMySqlIntegrationTest.java

104
docs/online-resume-frontend-handover-20261020.md

@ -0,0 +1,104 @@
# 本人在线简历:非 AI 接入约定
范围:基础信息与联系方式、求职意向、多段教育/工作/项目经历、技能详情、自我评价、在线简历保存与 DOCX 生成。此文对应工作区新增实现,不代表已经部署。既有附件上传下载、附件版本和权限规则继续使用原接口。
## 接口
以下路径均加 `/api/v1`;所有接口需要当前 STUDENT 的 Bearer Token。身份只来自认证上下文,不接受 userId 指定他人。COMPANY、ADMIN 不可使用,企业候选人响应不新增手机号或邮箱。
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/profile/me/online-resume` | 整份读取,首次 revision=0,不自动写库 |
| PUT | `/profile/me/online-resume` | 整份保存,body 为 `{revision, content}` |
| GET | `/profile/me/online-resume/{section}` | 某类条目列表 |
| POST | `/profile/me/online-resume/{section}` | 新增条目,body 为 `{revision, entry}` |
| GET | `/profile/me/online-resume/{section}/{id}` | 本人条目详情 |
| PUT | `/profile/me/online-resume/{section}/{id}` | 替换一条,body 为 `{revision, entry}` |
| DELETE | `/profile/me/online-resume/{section}/{id}?revision=数字` | 删除一条 |
| GET | `/profile/me/online-resume/download` | 当前已保存内容生成 DOCX,二进制响应 |
`section` 仅限 `education-experiences`、`work-experiences`、`project-experiences`、`skills`。动态路由不表示可修改其他字段。
GET、保存和条目修改的成功响应仍用 `{code:200,message:"ok",data:...}`。整份 data 为 `{revision,content,attachment,updatedAt}`;条目列表/详情只返回条目自身。attachment 无附件时为 null 或省略,有附件时包含 fileName/contentType/size/downloadUrl;downloadUrl 为鉴权接口地址,不能直接把存储键拼成公网 URL。
## 完整保存示例
```json
{
"revision": 0,
"content": {
"basicInfo": {
"realName": "合成测试",
"phone": "13800000000",
"email": "synthetic@example.test",
"location": "测试城市",
"education": "本科",
"school": "合成大学",
"graduationYear": 2022
},
"jobIntention": {
"targetPosition": "后端工程师",
"expectedSalaryMin": 10000,
"expectedSalaryMax": 20000,
"preferredCities": ["测试城市"],
"employmentTypes": ["FULL_TIME"],
"preferredIndustries": [],
"preferredJobTypes": [],
"remoteAllowed": false,
"jobSeekingStatus": "LOOKING",
"employmentStatus": "UNEMPLOYED",
"availableFrom": "2026-11-01"
},
"educationExperiences": [
{"school":"合成大学","major":"计算机","degree":"本科","startDate":"2018-09-01","endDate":"2022-06-30","ongoing":false,"description":"合成教育描述"}
],
"workExperiences": [
{"company":"合成企业","position":"开发工程师","startDate":"2022-07-01","ongoing":true,"description":"合成工作内容"}
],
"projectExperiences": [
{"name":"合成项目","role":"开发","startDate":"2023-01-01","ongoing":true,"description":"合成项目内容"}
],
"skills": [{"name":"Java","level":"PROFICIENT","years":3,"description":"合成技能说明"}],
"selfEvaluation": "合成自我评价"
}
}
```
这是完整替换接口,不能只提交当前编辑的一小块 content:空数组删除该类全部条目,可选字段 null/空字符串清空;省略某个集合会使用空集合,不能把省略理解为保持原值。局部编辑经历优先使用条目接口;基础信息、意向、自评编辑应先读完整内容,再修改相应块后整体保存。
新条目省略 id,服务器返回 UUID。保留既有条目时必须保留其 id;不存在或重复 id 返回 400,不接受自行指定新 id。条目 PUT 为完整替换,不能只提交某一个属性。条目顺序就是显示与导出的顺序。
## 字段及兼容
- basicInfo.phone/email 是本人简历联系方式,首次未保存时从账号手机号/邮箱带出。保存不修改登录手机号、账号邮箱或密码,不表示邮箱已验证。空联系方式可以清空;联系电话支持数字、开头加号、空格和常见座机分隔符,最多 30 字符。
- 姓名、所在地、单组学历/学校/毕业年份复用既有个人资料;selfEvaluation 对应既有 bio;skills 的 name 同步到既有技能列表。旧资料接口更新后,再读在线简历会反映最新基础资料和技能名称;同名技能保留熟练度等扩展信息。老接口 skills 仍为历史字符串,新接口 skills 才是对象数组。
- 多段 educationExperiences 与旧的单组 education/school/graduationYear 不自动推断相互覆盖;前端需要展示主学历时明确填写 basicInfo 对应字段。
- 薪资单位为元/月、非负且下限不得高于上限;城市、用工类型、行业、岗位类型、远程意愿复用已有求职偏好。
- jobSeekingStatus:LOOKING 正在求职、OPEN_TO_OPPORTUNITIES 考虑机会、NOT_LOOKING 暂不求职、STUDENT 在校求职身份。employmentStatus:EMPLOYED 在职、UNEMPLOYED 离职/未就业、STUDENT 在校。availableFrom 是可到岗日期。
- 每段经历 startDate 必填,使用 YYYY-MM-DD。ongoing=true 时 endDate 必须省略或为 null;否则 endDate 必填且不早于 startDate。教育必填学校/学历,工作必填公司/职位,项目必填名称。
- 教育最多 20 段,工作/项目各 30 段,技能 50 项;技能 name 不重复,level 为 BEGINNER/FAMILIAR/PROFICIENT/EXPERT,years 为 0—80 的整数。经历 description、自评各最多 5000 字;技能 description 最多 1000 字。其他长度约束见 OpenAPI。
- 在线简历保存不修改现有附件、不创建附件快照,也不把多段经历自动下发给企业或恢复任何简历授权。
## 保存、登录回填和错误
1. 页面进入或切回账号后 GET 完整简历,按当前账号重建页面状态。不能只依赖上传控件、缓存或上一次登录的本地对象。
2. 修改时提交最近一次成功响应中的 revision。成功后用响应整体替换页面数据,包括服务器分配的 id 和新 revision。
3. 所有在线简历入口共用 revision,整份保存和条目修改都会加一。HTTP 409 / code 6002 表示版本过期:提示用户刷新并合并编辑,不能自动拿新 revision 盲重试覆盖。
4. HTTP 404 / code 6003 表示 section 或本人条目不存在;400 表示字段、日期、id 或大小不合法;401/403 按既有登录、角色流程处理。
5. revision 保护的是这些新增在线简历写入入口。不要并发用旧资料/偏好接口和新接口编辑同一份表单;旧接口并未加入在线简历 revision 协议。
## 附件、预览与导出
已有附件仍使用 POST/GET/DELETE `/profile/me/resume`。文件名、大小从新 GET 的 attachment 恢复展示,不能通过简历控件是否还保留本地 File 对象判断服务器有无附件。
在线预览可由前端根据 content 渲染。下载按钮携带 Bearer 获取 `/profile/me/online-resume/download` 的 Blob,按响应文件名保存 `.docx`;生成内容为服务器当前已保存的各段内容,不包含上传附件正文,编辑后应先保存再下载。此接口生成可编辑 Word 文件,不提供 PDF 排版或 AI 处理。
## 数据与验证边界
V59 新建 online_resumes,保存扩展内容与 revision;不回写历史迁移,不迁移或解析真实简历内容。新增数据纳入本人个人数据导出,账号注销完成时清理相应在线简历记录。未自动开放企业读取。
后端测试和制品证据单独保存在 D 盘;测试通过不表示已发布,前端接入与线上发布仍需分别安排。
本批本地验证(2026-10-20):最终源码的在线简历业务测试在 H2 和 MySQL 8 各 16 项通过;MySQL 5.7/8 的 V58→V59 迁移及 V58 重投回归通过;两种 MySQL 的运行时/静态接口集合一致。最新 16 个测试套件报告合并为 502 项,0 失败、0 错误、0 跳过,是多次定向执行的汇总,不是一次全量测试。静态契约为 205 paths / 253 operations,既有 paths 和 schema 未改写。
证据目录:`D:\Workspaces\qsp-online-resume-20261020-113017`,详见 verification-summary.json、source-manifest.json、artifact-identity.json、logs/。构建副本基于 67b0957,只叠加本批白名单文件,不含两个受保护文件的工作区修改。本批尚未执行无筛选的全量 mvn verify、独立 JAR HTTP 黑盒或前端验收;DOCX 已做 ZIP/XML 结构检查,未做 Word 视觉渲染。未提交、推送、部署或运行线上迁移。

36
docs/online-resume-r01e-query-budget-20261020.md

@ -0,0 +1,36 @@
# 在线简历批次 R01-E 查询预算调整说明
日期:2026-10-20
范围:仅在线简历扩展数据进入个人数据导出;不改变其他章节的查询方式。
## 1. 变更
`PersonalDataCollector.collect(userId)` 在 profile、preferences 之后新增一次:
```text
onlineResumeRepository.findById(userId)
```
对应 SQL 只按 `online_resumes.user_id` 主键读取当前登录用户的一条记录,不按投递数量循环,也不读取其他用户记录。
因此 R01-E 的 E1 最小夹具预算由 **20 次固定 SQL** 调整为 **21 次固定 SQL**:
- 1 条投递:21
- 10 条投递:21
- 50 条投递:21
- H2 与真实 MySQL 均重新实测
- 所有被计数语句仍必须为 `SELECT`
- 丰富夹具下查询数不随记录数增长的断言保留
## 2. 独立校验
`R01EIndependentCrossDatabaseVerificationTest` 同时增加:
- `online_resumes` 查询恰好一次;
- SQL 含 `user_id` 条件与绑定参数;
- 夹具同时放入本人与他人的在线简历,导出结果只含本人内容;
- 清理夹具删除 `online_resumes`,残留检查包含该表。
这不是放宽上限,也不是通过缓存或跳过导出内容规避查询;新增的一次查询是导出包含本人在线简历所必需的主键读取。
历史文档 `docs/r01-e-independent-verification-20260918.md` 中的 20/20/20 保留为当时批次的实测记录,不回溯改写。

788
docs/openapi-rc.json

@ -198,6 +198,10 @@
}, },
{ {
"name": "行业技能词云" "name": "行业技能词云"
},
{
"name": "在线简历",
"description": "仅当前求职者本人的非 AI 在线简历"
} }
], ],
"paths": { "paths": {
@ -11860,6 +11864,289 @@
} }
} }
} }
},
"/profile/me/online-resume": {
"get": {
"tags": [
"在线简历"
],
"summary": "读取本人完整在线简历",
"description": "无保存记录时 revision=0,从已有资料、求职偏好和账号联系方式组装,不写库。attachment 仅返回文件元数据及鉴权下载地址。phone/email 为本人简历联系方式,不下发到企业候选人接口。",
"operationId": "getMyOnlineResume",
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseOnlineResumeView"
}
}
}
}
}
},
"put": {
"tags": [
"在线简历"
],
"summary": "保存完整在线简历",
"description": "携带最近 GET 的 revision;content 完整替换,空数组删除该类全部条目,null/空字符串可清空可选字段。新条目省略 id,既有条目保留 id。成功 revision+1;过期 revision 返回409/6002。同步既有个人资料和求职偏好,不改登录手机号/邮箱、附件及隐私授权。",
"operationId": "saveMyOnlineResume",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OnlineResumeSaveRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseOnlineResumeView"
}
}
}
}
}
}
},
"/profile/me/online-resume/{section}/{id}": {
"get": {
"tags": [
"在线简历"
],
"summary": "读取本人简历条目详情",
"description": "只在本人指定类别内寻找条目,不存在返回404/6003。",
"operationId": "getMyOnlineResumeEntry",
"parameters": [
{
"name": "section",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseObject"
}
}
}
}
}
},
"put": {
"tags": [
"在线简历"
],
"summary": "替换简历条目",
"description": "entry 完整替换本人条目;id 可省略或须与 URL 一致;携带整份简历 revision。",
"operationId": "updateMyOnlineResumeEntry",
"parameters": [
{
"name": "section",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OnlineResumeEntryRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseOnlineResumeView"
}
}
}
}
}
},
"delete": {
"tags": [
"在线简历"
],
"summary": "删除简历条目",
"description": "query revision 必填;不存在返回404/6003,过期版本返回409/6002;返回更新后的完整简历。",
"operationId": "deleteMyOnlineResumeEntry",
"parameters": [
{
"name": "section",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "revision",
"in": "query",
"required": true,
"schema": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseOnlineResumeView"
}
}
}
}
}
}
},
"/profile/me/online-resume/{section}": {
"get": {
"tags": [
"在线简历"
],
"summary": "读取某类简历条目",
"description": "section 仅 education-experiences/work-experiences/project-experiences/skills;revision 从整份简历 GET 获取。",
"operationId": "listMyOnlineResumeEntries",
"parameters": [
{
"name": "section",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseListObject"
}
}
}
}
}
},
"post": {
"tags": [
"在线简历"
],
"summary": "新增简历条目",
"description": "section 仅 education-experiences/work-experiences/project-experiences/skills;entry 为相应类型,新条目不传 id;携带整份简历 revision,返回更新后的完整简历。",
"operationId": "createMyOnlineResumeEntry",
"parameters": [
{
"name": "section",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OnlineResumeEntryRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseOnlineResumeView"
}
}
}
}
}
}
},
"/profile/me/online-resume/download": {
"get": {
"tags": [
"在线简历"
],
"summary": "下载在线简历 DOCX",
"description": "从当前已保存的在线内容生成 Word 文件,不含上传附件,不写文件、不创建附件版本;浏览器应携带鉴权获取 Blob,未保存编辑需先 PUT。",
"operationId": "downloadMyOnlineResume",
"responses": {
"200": {
"description": "OK",
"content": {
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": {
"schema": {
"type": "string",
"format": "byte"
}
}
}
}
}
}
} }
}, },
"components": { "components": {
@ -20890,6 +21177,507 @@
"description": "响应时间" "description": "响应时间"
} }
} }
},
"ApiResponseOnlineResumeView": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/OnlineResumeView"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"OnlineResumeView": {
"type": "object",
"properties": {
"revision": {
"type": "integer",
"format": "int64"
},
"content": {
"$ref": "#/components/schemas/OnlineResumeContent"
},
"attachment": {
"$ref": "#/components/schemas/OnlineResumeAttachment"
},
"updatedAt": {
"type": "string",
"format": "date-time"
}
}
},
"OnlineResumeContent": {
"type": "object",
"properties": {
"basicInfo": {
"$ref": "#/components/schemas/OnlineResumeBasicInfo"
},
"jobIntention": {
"$ref": "#/components/schemas/OnlineResumeJobIntention"
},
"educationExperiences": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OnlineResumeEducation"
},
"maxItems": 20,
"minItems": 0
},
"workExperiences": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OnlineResumeWork"
},
"maxItems": 30,
"minItems": 0
},
"projectExperiences": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OnlineResumeProject"
},
"maxItems": 30,
"minItems": 0
},
"skills": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OnlineResumeSkill"
},
"maxItems": 50,
"minItems": 0
},
"selfEvaluation": {
"type": "string",
"maxLength": 5000,
"minLength": 0
}
},
"required": [
"basicInfo",
"educationExperiences",
"jobIntention",
"projectExperiences",
"skills",
"workExperiences"
]
},
"OnlineResumeProject": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "[a-f0-9\\-]{36}"
},
"startDate": {
"type": "string",
"format": "date"
},
"endDate": {
"type": "string",
"format": "date"
},
"ongoing": {
"type": "boolean"
},
"description": {
"type": "string",
"maxLength": 5000,
"minLength": 0
},
"name": {
"type": "string",
"maxLength": 150,
"minLength": 0
},
"role": {
"type": "string",
"maxLength": 120,
"minLength": 0
}
},
"required": [
"name",
"startDate"
]
},
"OnlineResumeAttachment": {
"type": "object",
"properties": {
"fileName": {
"type": "string"
},
"contentType": {
"type": "string"
},
"size": {
"type": "integer",
"format": "int64"
},
"downloadUrl": {
"type": "string"
}
}
},
"OnlineResumeJobIntention": {
"type": "object",
"properties": {
"targetPosition": {
"type": "string",
"maxLength": 120,
"minLength": 0
},
"expectedSalaryMin": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"expectedSalaryMax": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"preferredCities": {
"type": "array",
"items": {
"type": "string",
"maxLength": 100,
"minLength": 0
},
"maxItems": 20,
"minItems": 0
},
"employmentTypes": {
"type": "array",
"items": {
"type": "string",
"maxLength": 100,
"minLength": 0
},
"maxItems": 20,
"minItems": 0
},
"preferredIndustries": {
"type": "array",
"items": {
"type": "string",
"maxLength": 100,
"minLength": 0
},
"maxItems": 20,
"minItems": 0
},
"preferredJobTypes": {
"type": "array",
"items": {
"type": "string",
"maxLength": 100,
"minLength": 0
},
"maxItems": 20,
"minItems": 0
},
"remoteAllowed": {
"type": "boolean"
},
"jobSeekingStatus": {
"type": "string",
"enum": [
"LOOKING",
"OPEN_TO_OPPORTUNITIES",
"NOT_LOOKING",
"STUDENT"
]
},
"employmentStatus": {
"type": "string",
"enum": [
"EMPLOYED",
"UNEMPLOYED",
"STUDENT"
]
},
"availableFrom": {
"type": "string",
"format": "date"
}
},
"required": [
"employmentTypes",
"preferredCities",
"preferredIndustries",
"preferredJobTypes"
]
},
"OnlineResumeBasicInfo": {
"type": "object",
"properties": {
"realName": {
"type": "string",
"maxLength": 50,
"minLength": 0
},
"phone": {
"type": "string",
"maxLength": 30,
"minLength": 0,
"pattern": "^$|\\+?[0-9][0-9() -]{4,29}"
},
"email": {
"type": "string",
"format": "email",
"maxLength": 100,
"minLength": 0
},
"location": {
"type": "string",
"maxLength": 100,
"minLength": 0
},
"education": {
"type": "string",
"maxLength": 100,
"minLength": 0
},
"school": {
"type": "string",
"maxLength": 150,
"minLength": 0
},
"graduationYear": {
"type": "integer",
"format": "int32",
"maximum": 2200,
"minimum": 1900
}
}
},
"OnlineResumeEntryRequest": {
"type": "object",
"properties": {
"revision": {
"type": "integer",
"format": "int64"
},
"entry": {
"oneOf": [
{
"$ref": "#/components/schemas/OnlineResumeEducation"
},
{
"$ref": "#/components/schemas/OnlineResumeWork"
},
{
"$ref": "#/components/schemas/OnlineResumeProject"
},
{
"$ref": "#/components/schemas/OnlineResumeSkill"
}
]
}
},
"required": [
"entry",
"revision"
]
},
"OnlineResumeSkill": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "[a-f0-9\\-]{36}"
},
"name": {
"type": "string",
"maxLength": 100,
"minLength": 0
},
"level": {
"type": "string",
"enum": [
"BEGINNER",
"FAMILIAR",
"PROFICIENT",
"EXPERT"
]
},
"years": {
"type": "integer",
"format": "int32",
"maximum": 80,
"minimum": 0
},
"description": {
"type": "string",
"maxLength": 1000,
"minLength": 0
}
},
"required": [
"name"
]
},
"ApiResponseObject": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"OnlineResumeWork": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "[a-f0-9\\-]{36}"
},
"startDate": {
"type": "string",
"format": "date"
},
"endDate": {
"type": "string",
"format": "date"
},
"ongoing": {
"type": "boolean"
},
"description": {
"type": "string",
"maxLength": 5000,
"minLength": 0
},
"company": {
"type": "string",
"maxLength": 150,
"minLength": 0
},
"position": {
"type": "string",
"maxLength": 120,
"minLength": 0
}
},
"required": [
"company",
"position",
"startDate"
]
},
"ApiResponseListObject": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {
"type": "array",
"items": {}
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"OnlineResumeEducation": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "[a-f0-9\\-]{36}"
},
"startDate": {
"type": "string",
"format": "date"
},
"endDate": {
"type": "string",
"format": "date"
},
"ongoing": {
"type": "boolean"
},
"description": {
"type": "string",
"maxLength": 5000,
"minLength": 0
},
"school": {
"type": "string",
"maxLength": 150,
"minLength": 0
},
"major": {
"type": "string",
"maxLength": 150,
"minLength": 0
},
"degree": {
"type": "string",
"maxLength": 100,
"minLength": 0
}
},
"required": [
"degree",
"school",
"startDate"
]
},
"OnlineResumeSaveRequest": {
"type": "object",
"properties": {
"revision": {
"type": "integer",
"format": "int64"
},
"content": {
"$ref": "#/components/schemas/OnlineResumeContent"
}
},
"required": [
"content",
"revision"
]
} }
}, },
"securitySchemes": { "securitySchemes": {

22
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) print('paths:', len(d['paths']))
assert len(d['paths']) == 201, f"Expected 201 paths, got {len(d['paths'])}" assert len(d['paths']) == 205, f"Expected 205 paths, got {len(d['paths'])}"
paths = d['paths'] paths = d['paths']
@ -719,7 +719,7 @@ print(' S03 company resume download contract validated; candidate DTOs remain r
# 统计 operation 总数并校验当前接口契约基线。 # 统计 operation 总数并校验当前接口契约基线。
operations = sum(len(methods) for methods in d['paths'].values()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) print('operations:', operations)
assert operations == 245, f"Expected 245 operations, got {operations}" assert operations == 253, f"Expected 253 operations, got {operations}"
assert 'post' in paths['/conversations/{id}/resume-messages'] assert 'post' in paths['/conversations/{id}/resume-messages']
assert {'get', 'delete'} <= paths['/conversations/{conversationId}/messages/{messageId}/resume'].keys() assert {'get', 'delete'} <= paths['/conversations/{conversationId}/messages/{messageId}/resume'].keys()
assert {'messageType', 'resume'} <= d['components']['schemas']['MessageResponse']['properties'].keys() assert {'messageType', 'resume'} <= d['components']['schemas']['MessageResponse']['properties'].keys()
@ -886,5 +886,23 @@ for _name in ('industry', 'experience', 'education'):
for _option in ('industries', 'experiences', 'educations'): for _option in ('industries', 'experiences', 'educations'):
assert _option in paths['/jobs/filters']['get']['description'] assert _option in paths['/jobs/filters']['get']['description']
print(' 岗位行业/经验/学历多选参数和动态选项说明已验证') print(' 岗位行业/经验/学历多选参数和动态选项说明已验证')
_online = '/profile/me/online-resume'
assert set(paths[_online]) & HTTP_METHODS == {'get', 'put'}
assert set(paths[_online + '/{section}']) & HTTP_METHODS == {'get', 'post'}
assert set(paths[_online + '/{section}/{id}']) & HTTP_METHODS == {'get', 'put', 'delete'}
assert 'get' in paths[_online + '/download']
_online_schemas = d['components']['schemas']
assert {'phone', 'email'} <= set(_online_schemas['OnlineResumeBasicInfo']['properties'])
assert {'educationExperiences', 'workExperiences', 'projectExperiences', 'skills', 'selfEvaluation'} <= set(_online_schemas['OnlineResumeContent']['properties'])
assert {'jobSeekingStatus', 'employmentStatus', 'availableFrom'} <= set(_online_schemas['OnlineResumeJobIntention']['properties'])
assert {'fileName', 'contentType', 'size', 'downloadUrl'} <= set(_online_schemas['OnlineResumeAttachment']['properties'])
assert '6002' in paths[_online]['put']['description']
_entry = _online_schemas['OnlineResumeEntryRequest']['properties']['entry']
assert '$ref' not in _entry
assert len(_entry['oneOf']) == 4
assert len({item['$ref'] for item in _entry['oneOf']}) == 4
for _candidate in ('CandidateItemView', 'CandidateDetailView'):
assert not ({'phone', 'email'} & set(_online_schemas[_candidate]['properties']))
print(' Online resume: 4 paths / 8 operations, contacts and experience schemas verified')
print("Valid JSON: True") print("Valid JSON: True")
print("All checks passed!") print("All checks passed!")

2
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java

@ -41,6 +41,8 @@ public enum ErrorCode {
// 学生资料 6xxx // 学生资料 6xxx
PROFILE_NOT_FOUND(6001, "profile not found"), PROFILE_NOT_FOUND(6001, "profile not found"),
ONLINE_RESUME_REVISION_CONFLICT(6002, "online resume changed; refresh before saving"),
ONLINE_RESUME_ENTRY_NOT_FOUND(6003, "online resume entry not found"),
// 岗位 7xxx // 岗位 7xxx
JOB_NOT_FOUND(7001, "job not found"), JOB_NOT_FOUND(7001, "job not found"),

2
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java

@ -138,6 +138,7 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND, ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND,
ErrorCode.OFFER_NOT_FOUND, ErrorCode.OFFER_NOT_FOUND,
ErrorCode.PROFILE_NOT_FOUND, ErrorCode.PROFILE_NOT_FOUND,
ErrorCode.ONLINE_RESUME_ENTRY_NOT_FOUND,
ErrorCode.REPORT_NOT_FOUND, ErrorCode.REPORT_NOT_FOUND,
ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND, ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND,
ErrorCode.RESUME_VERSION_NOT_FOUND, ErrorCode.RESUME_VERSION_NOT_FOUND,
@ -149,6 +150,7 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.USER_NOT_FOUND, ErrorCode.USER_NOT_FOUND,
ErrorCode.VERIFICATION_NOT_FOUND) ErrorCode.VERIFICATION_NOT_FOUND)
.put(HttpStatus.CONFLICT, .put(HttpStatus.CONFLICT,
ErrorCode.ONLINE_RESUME_REVISION_CONFLICT,
ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE, ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE,
ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED, ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED,
ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT, ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT,

4
mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java

@ -90,6 +90,7 @@ public class PersonalDataCollector {
private final CandidateScoringAppealHistoryRepository candidateScoringAppealHistoryRepository; private final CandidateScoringAppealHistoryRepository candidateScoringAppealHistoryRepository;
private final CompanyRepository companyRepository; private final CompanyRepository companyRepository;
private final ResumeStorageService resumeStorageService; private final ResumeStorageService resumeStorageService;
private final com.mydreamjob.onlineresume.OnlineResumeRepository onlineResumeRepository;
@Getter @Getter
public static class CollectResult { public static class CollectResult {
@ -102,6 +103,9 @@ public class PersonalDataCollector {
List<Application> applications = applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(userId); List<Application> applications = applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(userId);
r.getSections().put("profile", buildProfile(userId)); r.getSections().put("profile", buildProfile(userId));
r.getSections().put("preferences", buildPreferences(userId)); r.getSections().put("preferences", buildPreferences(userId));
onlineResumeRepository.findById(userId).ifPresent(resume ->
r.getSections().put("online-resume", Map.of("revision", resume.getRevision(),
"content", resume.getContentJson(), "updatedAt", resume.getUpdatedAt())));
buildResumes(r, userId); buildResumes(r, userId);
buildApplications(r, applications); buildApplications(r, applications);
buildInterviews(r, userId); buildInterviews(r, userId);

2
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java

@ -40,6 +40,7 @@ public class AccountDeletionCompletionWorker {
private final AuthSessionService authSessionService; private final AuthSessionService authSessionService;
private final Clock clock; private final Clock clock;
private final com.mydreamjob.messaging.ChatResumeLifecycle chatResumeLifecycle; private final com.mydreamjob.messaging.ChatResumeLifecycle chatResumeLifecycle;
private final com.mydreamjob.onlineresume.OnlineResumeRepository onlineResumeRepository;
/** /**
* 若用户处于 PENDING 且已到期,则执行完成态迁移 + 脱敏 + 审计。 * 若用户处于 PENDING 且已到期,则执行完成态迁移 + 脱敏 + 审计。
@ -73,6 +74,7 @@ public class AccountDeletionCompletionWorker {
dataExportCleanupService.purgeForDeletedUser(rec.getUserId()); dataExportCleanupService.purgeForDeletedUser(rec.getUserId());
salaryHistoryCleanupService.purgeForDeletedUser(rec.getUserId()); salaryHistoryCleanupService.purgeForDeletedUser(rec.getUserId());
chatResumeLifecycle.purgeForUser(rec.getUserId()); chatResumeLifecycle.purgeForUser(rec.getUserId());
onlineResumeRepository.deleteById(rec.getUserId());
// 完成 + 脱敏审计各写一次 // 完成 + 脱敏审计各写一次
auditService.log(userId, "ACCOUNT_DELETION_COMPLETED", "ACCOUNT_DELETION", auditService.log(userId, "ACCOUNT_DELETION_COMPLETED", "ACCOUNT_DELETION",
rec.getId(), "account deletion completed; userId=" + userId rec.getId(), "account deletion completed; userId=" + userId

16
mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResume.java

@ -0,0 +1,16 @@
package com.mydreamjob.onlineresume;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "online_resumes")
@Getter @Setter @NoArgsConstructor
public class OnlineResume {
@Id private Long userId;
@Column(nullable = false) private long revision;
@Column(name = "content_json", nullable = false, columnDefinition = "longtext")
private String contentJson;
@Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt;
}

95
mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeContent.java

@ -0,0 +1,95 @@
package com.mydreamjob.onlineresume;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/** 本人在线简历。PUT 为完整替换;经历 id 由服务器生成,更新时保留。 */
@Data
public class OnlineResumeContent {
@Valid @NotNull private BasicInfo basicInfo = new BasicInfo();
@Valid @NotNull private JobIntention jobIntention = new JobIntention();
@Valid @NotNull @Size(max = 20) private List<@NotNull Education> educationExperiences = new ArrayList<>();
@Valid @NotNull @Size(max = 30) private List<@NotNull Work> workExperiences = new ArrayList<>();
@Valid @NotNull @Size(max = 30) private List<@NotNull Project> projectExperiences = new ArrayList<>();
@Valid @NotNull @Size(max = 50) private List<@NotNull Skill> skills = new ArrayList<>();
@Size(max = 5000) private String selfEvaluation;
@Data @io.swagger.v3.oas.annotations.media.Schema(name = "OnlineResumeBasicInfo")
public static class BasicInfo {
@Size(max = 50) private String realName;
// 这是简历联系方式,不修改 users.phone / users.email 或登录认证。
@Size(max = 30) @Pattern(regexp = "^$|\\+?[0-9][0-9() -]{4,29}") private String phone;
@Email @Size(max = 100) private String email;
@Size(max = 100) private String location;
@Size(max = 100) private String education;
@Size(max = 150) private String school;
@Min(1900) @Max(2200) private Integer graduationYear;
}
public enum JobSeekingStatus { LOOKING, OPEN_TO_OPPORTUNITIES, NOT_LOOKING, STUDENT }
public enum EmploymentStatus { EMPLOYED, UNEMPLOYED, STUDENT }
@Data @io.swagger.v3.oas.annotations.media.Schema(name = "OnlineResumeJobIntention")
public static class JobIntention {
@Size(max = 120) private String targetPosition;
@Min(0) private Integer expectedSalaryMin;
@Min(0) private Integer expectedSalaryMax;
@NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> preferredCities = new ArrayList<>();
@NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> employmentTypes = new ArrayList<>();
@NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> preferredIndustries = new ArrayList<>();
@NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> preferredJobTypes = new ArrayList<>();
private boolean remoteAllowed;
private JobSeekingStatus jobSeekingStatus;
private EmploymentStatus employmentStatus;
private LocalDate availableFrom;
@AssertTrue(message = "expectedSalaryMin must not exceed expectedSalaryMax")
@com.fasterxml.jackson.annotation.JsonIgnore
public boolean isSalaryRangeValid() {
return expectedSalaryMin == null || expectedSalaryMax == null || expectedSalaryMin <= expectedSalaryMax;
}
}
@Data @io.swagger.v3.oas.annotations.media.Schema(name = "OnlineResumeDatedEntry")
public static class DatedEntry {
@Pattern(regexp = "[a-f0-9\\-]{36}") private String id;
@NotNull private LocalDate startDate;
private LocalDate endDate;
private boolean ongoing;
@Size(max = 5000) private String description;
@AssertTrue(message = "ongoing entries have no endDate; otherwise endDate >= startDate is required")
@com.fasterxml.jackson.annotation.JsonIgnore
public boolean isDateRangeValid() {
return ongoing ? endDate == null : startDate != null && endDate != null && !endDate.isBefore(startDate);
}
}
@Data @lombok.EqualsAndHashCode(callSuper = true) @io.swagger.v3.oas.annotations.media.Schema(name = "OnlineResumeEducation", additionalProperties = io.swagger.v3.oas.annotations.media.Schema.AdditionalPropertiesValue.FALSE)
public static class Education extends DatedEntry {
@NotBlank @Size(max = 150) private String school;
@Size(max = 150) private String major;
@NotBlank @Size(max = 100) private String degree;
}
@Data @lombok.EqualsAndHashCode(callSuper = true) @io.swagger.v3.oas.annotations.media.Schema(name = "OnlineResumeWork", additionalProperties = io.swagger.v3.oas.annotations.media.Schema.AdditionalPropertiesValue.FALSE)
public static class Work extends DatedEntry {
@NotBlank @Size(max = 150) private String company;
@NotBlank @Size(max = 120) private String position;
}
@Data @lombok.EqualsAndHashCode(callSuper = true) @io.swagger.v3.oas.annotations.media.Schema(name = "OnlineResumeProject", additionalProperties = io.swagger.v3.oas.annotations.media.Schema.AdditionalPropertiesValue.FALSE)
public static class Project extends DatedEntry {
@NotBlank @Size(max = 150) private String name;
@Size(max = 120) private String role;
}
public enum SkillLevel { BEGINNER, FAMILIAR, PROFICIENT, EXPERT }
@Data @io.swagger.v3.oas.annotations.media.Schema(name = "OnlineResumeSkill", additionalProperties = io.swagger.v3.oas.annotations.media.Schema.AdditionalPropertiesValue.FALSE)
public static class Skill {
@Pattern(regexp = "[a-f0-9\\-]{36}") private String id;
@NotBlank @Size(max = 100) private String name;
private SkillLevel level;
@Min(0) @Max(80) private Integer years;
@Size(max = 1000) private String description;
}
}

97
mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeController.java

@ -0,0 +1,97 @@
package com.mydreamjob.onlineresume;
import com.fasterxml.jackson.databind.*;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.*;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.*;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import lombok.RequiredArgsConstructor;
import org.springframework.http.*;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
@RestController @RequiredArgsConstructor @Validated
@RequestMapping("/profile/me/online-resume")
@Tag(name = "在线简历", description = "仅当前 STUDENT 本人;不修改账号登录信息,不读取附件正文")
public class OnlineResumeController {
private final OnlineResumeService service;
private final OnlineResumeDocxWriter docx;
private final CurrentUserAccessor currentUser;
private final RequestIdAccessor requestIds;
private final ObjectMapper mapper;
@Schema(name = "OnlineResumeEntryRequest")
@lombok.Data
public static class EntryRequest {
@NotNull @PositiveOrZero private Long revision;
@NotNull @Schema(implementation = Object.class, oneOf = {OnlineResumeContent.Education.class, OnlineResumeContent.Work.class,
OnlineResumeContent.Project.class, OnlineResumeContent.Skill.class})
private JsonNode entry;
}
@GetMapping
@Operation(operationId = "getMyOnlineResume", summary = "读取本人完整在线简历", description = "无保存记录时 revision=0,从已有资料、求职偏好和账号联系方式组装,不写库。attachment 仅返回文件元数据及鉴权下载地址。phone/email 为本人简历联系方式,不下发到企业候选人接口。")
public ApiResponse<OnlineResumeView> get(HttpServletRequest request) {
return ApiResponse.ok(service.get(currentUser.requireUserId()), requestIds.get(request));
}
@PutMapping
@Operation(operationId = "saveMyOnlineResume", summary = "保存完整在线简历", description = "携带最近 GET 的 revision;content 完整替换,空数组删除该类全部条目,null/空字符串可清空可选字段。新条目省略 id,既有条目保留 id。成功 revision+1;过期 revision 返回409/6002。同步既有个人资料和求职偏好,不改登录手机号/邮箱、附件及隐私授权。")
public ApiResponse<OnlineResumeView> save(@Valid @RequestBody OnlineResumeSaveRequest body, HttpServletRequest request) {
return ApiResponse.ok(service.save(currentUser.requireUserId(), body), requestIds.get(request));
}
@GetMapping("/{section}")
@Operation(operationId = "listMyOnlineResumeEntries", summary = "读取某类简历条目", description = "section 仅 education-experiences/work-experiences/project-experiences/skills;revision 从整份简历 GET 获取。")
public ApiResponse<java.util.List<Object>> list(@PathVariable String section, HttpServletRequest request) {
java.util.List<Object> result = new java.util.ArrayList<>();
entries(service.get(currentUser.requireUserId()), section).forEach(result::add);
return ApiResponse.ok(result, requestIds.get(request));
}
@GetMapping("/{section}/{id}")
@Operation(operationId = "getMyOnlineResumeEntry", summary = "读取本人简历条目详情", description = "只在本人指定类别内寻找条目,不存在返回404/6003。")
public ApiResponse<Object> detail(@PathVariable String section, @PathVariable String id, HttpServletRequest request) {
for (JsonNode e : entries(service.get(currentUser.requireUserId()), section))
if (id.equals(e.path("id").asText())) return ApiResponse.ok(e, requestIds.get(request));
throw new BusinessException(ErrorCode.ONLINE_RESUME_ENTRY_NOT_FOUND);
}
@PostMapping("/{section}")
@Operation(operationId = "createMyOnlineResumeEntry", summary = "新增简历条目", description = "section 仅 education-experiences/work-experiences/project-experiences/skills;entry 为相应类型,新条目不传 id;携带整份简历 revision,返回更新后的完整简历。")
public ApiResponse<OnlineResumeView> create(@PathVariable String section, @Valid @RequestBody EntryRequest body, HttpServletRequest request) {
return ApiResponse.ok(service.editEntry(currentUser.requireUserId(), section, null, body.getRevision(), body.getEntry(), false), requestIds.get(request));
}
@PutMapping("/{section}/{id}")
@Operation(operationId = "updateMyOnlineResumeEntry", summary = "替换简历条目", description = "entry 完整替换本人条目;id 可省略或须与 URL 一致;携带整份简历 revision。")
public ApiResponse<OnlineResumeView> update(@PathVariable String section, @PathVariable String id, @Valid @RequestBody EntryRequest body, HttpServletRequest request) {
return ApiResponse.ok(service.editEntry(currentUser.requireUserId(), section, id, body.getRevision(), body.getEntry(), false), requestIds.get(request));
}
@DeleteMapping("/{section}/{id}")
@Operation(operationId = "deleteMyOnlineResumeEntry", summary = "删除简历条目", description = "query revision 必填;不存在返回404/6003,过期版本返回409/6002;返回更新后的完整简历。")
public ApiResponse<OnlineResumeView> delete(@PathVariable String section, @PathVariable String id,
@RequestParam @Min(0) Long revision, HttpServletRequest request) {
return ApiResponse.ok(service.editEntry(currentUser.requireUserId(), section, id, revision, null, true), requestIds.get(request));
}
@GetMapping(value = "/download", produces = "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
@Operation(operationId = "downloadMyOnlineResume", summary = "下载在线简历 DOCX", description = "从当前已保存的在线内容生成 Word 文件,不含上传附件,不写文件、不创建附件版本;浏览器应携带鉴权获取 Blob,未保存编辑需先 PUT。")
public ResponseEntity<byte[]> download() {
byte[] bytes = docx.write(service.get(currentUser.requireUserId()).content());
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename("个人简历.docx", StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
.contentLength(bytes.length).body(bytes);
}
private JsonNode entries(OnlineResumeView view, String section) {
return mapper.valueToTree(switch (section) {
case "education-experiences" -> view.content().getEducationExperiences();
case "work-experiences" -> view.content().getWorkExperiences();
case "project-experiences" -> view.content().getProjectExperiences();
case "skills" -> view.content().getSkills();
default -> throw new BusinessException(ErrorCode.ONLINE_RESUME_ENTRY_NOT_FOUND);
});
}
}

70
mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeDocxWriter.java

@ -0,0 +1,70 @@
package com.mydreamjob.onlineresume;
import org.springframework.stereotype.Component;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.zip.*;
/** 生成真正的 OOXML 文档;用户文本只作为文字写入,无外链、宏或文件读取。 */
@Component
public class OnlineResumeDocxWriter {
public byte[] write(OnlineResumeContent c) {
List<String> lines = new ArrayList<>();
var b = c.getBasicInfo();
lines.add(b.getRealName() == null || b.getRealName().isBlank() ? "个人简历" : b.getRealName() + "的简历");
lines.add(join(b.getPhone(), b.getEmail(), b.getLocation()));
lines.add(join(b.getEducation(), b.getSchool(), b.getGraduationYear()));
var j = c.getJobIntention();
lines.add("求职意向"); lines.add(join(j.getTargetPosition(), String.join("、", j.getPreferredCities())));
if (j.getExpectedSalaryMin() != null || j.getExpectedSalaryMax() != null)
lines.add("期望月薪(元):" + Objects.toString(j.getExpectedSalaryMin(), "不限") + "—" + Objects.toString(j.getExpectedSalaryMax(), "不限"));
lines.add(join(String.join("、", j.getEmploymentTypes()), String.join("、", j.getPreferredIndustries()), String.join("、", j.getPreferredJobTypes())));
if (j.getJobSeekingStatus() != null) lines.add("求职状态:" + switch (j.getJobSeekingStatus()) {
case LOOKING -> "正在求职"; case OPEN_TO_OPPORTUNITIES -> "考虑机会";
case NOT_LOOKING -> "暂不求职"; case STUDENT -> "在校学生";
});
if (j.getAvailableFrom() != null) lines.add("可到岗日期:" + j.getAvailableFrom());
if (j.getEmploymentStatus() != null) lines.add("当前状态:" + switch (j.getEmploymentStatus()) {
case EMPLOYED -> "在职"; case UNEMPLOYED -> "离职/未就业"; case STUDENT -> "在校";
});
if (j.isRemoteAllowed()) lines.add("接受远程工作");
lines.add("教育经历");
for (var e : c.getEducationExperiences()) { lines.add(join(e.getSchool(), e.getMajor(), e.getDegree(), dates(e))); lines.add(e.getDescription()); }
lines.add("工作经历");
for (var e : c.getWorkExperiences()) { lines.add(join(e.getCompany(), e.getPosition(), dates(e))); lines.add(e.getDescription()); }
lines.add("项目经历");
for (var e : c.getProjectExperiences()) { lines.add(join(e.getName(), e.getRole(), dates(e))); lines.add(e.getDescription()); }
lines.add("技能特长");
for (var e : c.getSkills()) {
String level = e.getLevel() == null ? null : switch (e.getLevel()) {
case BEGINNER -> "入门"; case FAMILIAR -> "熟悉"; case PROFICIENT -> "熟练"; case EXPERT -> "精通";
};
lines.add(join(e.getName(), level, e.getYears() == null ? null : e.getYears() + "年")); lines.add(e.getDescription());
}
lines.add("自我评价"); lines.add(c.getSelfEvaluation());
StringBuilder body = new StringBuilder();
for (String text : lines) {
if (text == null || text.isBlank()) continue;
for (String line : text.split("\\R", -1)) body.append("<w:p><w:pPr><w:spacing w:after=\"120\"/></w:pPr><w:r><w:rPr><w:rFonts w:ascii=\"Calibri\" w:eastAsia=\"Microsoft YaHei\"/><w:sz w:val=\"22\"/></w:rPr><w:t xml:space=\"preserve\">")
.append(xml(line)).append("</w:t></w:r></w:p>");
}
body.append("<w:sectPr><w:pgSz w:w=\"11906\" w:h=\"16838\"/><w:pgMar w:top=\"1134\" w:right=\"1134\" w:bottom=\"1134\" w:left=\"1134\"/></w:sectPr>");
try (var out = new ByteArrayOutputStream(); var zip = new ZipOutputStream(out, StandardCharsets.UTF_8)) {
add(zip, "[Content_Types].xml", "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/></Types>");
add(zip, "_rels/.rels", "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"word/document.xml\"/></Relationships>");
add(zip, "word/document.xml", "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>" + body + "</w:body></w:document>");
zip.finish(); return out.toByteArray();
} catch (IOException e) { throw new IllegalStateException("cannot generate resume document", e); }
}
private String dates(OnlineResumeContent.DatedEntry e) { return Objects.toString(e.getStartDate(), "") + "—" + (e.isOngoing() ? "至今" : Objects.toString(e.getEndDate(), "")); }
private String join(Object... fields) { return Arrays.stream(fields).filter(Objects::nonNull).map(Object::toString).filter(s -> !s.isBlank()).collect(java.util.stream.Collectors.joining(" · ")); }
private String xml(String s) {
StringBuilder clean = new StringBuilder();
s.codePoints().filter(c -> c == 9 || c == 10 || c == 13 || c >= 32 && c <= 0xd7ff || c >= 0xe000 && c <= 0xfffd || c >= 0x10000 && c <= 0x10ffff).forEach(clean::appendCodePoint);
return clean.toString().replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&apos;");
}
private void add(ZipOutputStream zip, String name, String text) throws IOException {
zip.putNextEntry(new ZipEntry(name)); zip.write(text.getBytes(StandardCharsets.UTF_8)); zip.closeEntry();
}
}

29
mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeOpenApiConfiguration.java

@ -0,0 +1,29 @@
package com.mydreamjob.onlineresume;
import io.swagger.v3.oas.models.media.Schema;
import org.springdoc.core.customizers.OpenApiCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.ArrayList;
import java.util.LinkedHashSet;
@Configuration(proxyBeanMethods = false)
public class OnlineResumeOpenApiConfiguration {
@Bean
OpenApiCustomizer onlineResumeEntrySchema() {
return api -> {
if (api.getComponents() == null || api.getComponents().getSchemas() == null) return;
Schema<?> request = api.getComponents().getSchemas().get("OnlineResumeEntryRequest");
if (request == null || request.getProperties() == null) return;
Schema<?> entry = request.getProperties().get("entry");
if (entry == null || entry.getOneOf() == null) return;
// Springdoc 2.8 的 JsonNode + implementation 注解会重复合并 oneOf。
// oneOf 中相同分支出现两次将导致合法对象也无法通过 schema 校验。
var seen = new LinkedHashSet<String>();
var unique = new ArrayList<Schema>();
for (Schema variant : entry.getOneOf())
if (variant.get$ref() == null || seen.add(variant.get$ref())) unique.add(variant);
entry.setOneOf(unique);
};
}
}

5
mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeRepository.java

@ -0,0 +1,5 @@
package com.mydreamjob.onlineresume;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OnlineResumeRepository extends JpaRepository<OnlineResume, Long> {}

7
mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeSaveRequest.java

@ -0,0 +1,7 @@
package com.mydreamjob.onlineresume;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
public record OnlineResumeSaveRequest(@NotNull @PositiveOrZero Long revision,
@NotNull @Valid OnlineResumeContent content) {}

233
mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeService.java

@ -0,0 +1,233 @@
package com.mydreamjob.onlineresume;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.*;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import com.mydreamjob.common.exception.*;
import com.mydreamjob.preference.*;
import com.mydreamjob.profile.*;
import com.mydreamjob.security.UserRoleGuard;
import jakarta.persistence.*;
import jakarta.validation.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Function;
@Service @RequiredArgsConstructor
public class OnlineResumeService {
private final OnlineResumeRepository resumes;
private final UserRepository users;
private final StudentProfileRepository profiles;
private final StudentPreferenceRepository preferences;
private final CandidateScoringRefreshRequestService refresh;
private final UserRoleGuard roles;
private final ObjectMapper mapper;
private final Validator validator;
@PersistenceContext private EntityManager em;
@Transactional(readOnly = true)
public OnlineResumeView get(Long userId) {
roles.requireStudent(userId);
User u = users.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
return view(u, resumes.findById(userId).orElse(null));
}
@Transactional
public OnlineResumeView save(Long userId, OnlineResumeSaveRequest request) {
User u = lockUser(userId);
OnlineResume row = resumes.findById(userId).orElse(null);
OnlineResumeView current = view(u, row);
checkRevision(current.revision(), request.revision());
return persist(u, row, request.content(), current.content());
}
/** 四种条目共用整份简历 revision,避免局部编辑与整份保存互相覆盖。 */
@Transactional
public OnlineResumeView editEntry(Long userId, String section, String id, Long revision,
JsonNode entry, boolean delete) {
User u = lockUser(userId);
OnlineResume row = resumes.findById(userId).orElse(null);
OnlineResumeView current = view(u, row);
checkRevision(current.revision(), revision);
OnlineResumeContent content = copy(current.content());
switch (section) {
case "education-experiences" -> edit(content.getEducationExperiences(), id, entry, delete, OnlineResumeContent.Education.class,
OnlineResumeContent.Education::getId, OnlineResumeContent.Education::setId);
case "work-experiences" -> edit(content.getWorkExperiences(), id, entry, delete, OnlineResumeContent.Work.class,
OnlineResumeContent.Work::getId, OnlineResumeContent.Work::setId);
case "project-experiences" -> edit(content.getProjectExperiences(), id, entry, delete, OnlineResumeContent.Project.class,
OnlineResumeContent.Project::getId, OnlineResumeContent.Project::setId);
case "skills" -> edit(content.getSkills(), id, entry, delete, OnlineResumeContent.Skill.class,
OnlineResumeContent.Skill::getId, OnlineResumeContent.Skill::setId);
default -> throw new BusinessException(ErrorCode.ONLINE_RESUME_ENTRY_NOT_FOUND);
}
return persist(u, row, content, current.content());
}
private <T> void edit(List<T> list, String id, JsonNode json, boolean delete, Class<T> type,
Function<T, String> getId, java.util.function.BiConsumer<T, String> setId) {
int index = -1;
for (int i = 0; i < list.size(); i++) if (Objects.equals(getId.apply(list.get(i)), id)) index = i;
if (id != null && index < 0) throw new BusinessException(ErrorCode.ONLINE_RESUME_ENTRY_NOT_FOUND);
if (delete) { list.remove(index); return; }
T value;
try {
value = mapper.readerFor(type).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(json);
} catch (java.io.IOException | IllegalArgumentException e) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid resume entry");
}
if (value == null || getId.apply(value) != null && !Objects.equals(getId.apply(value), id))
throw new BusinessException(ErrorCode.BAD_REQUEST, "entry id must match the URL; new entries omit id");
setId.accept(value, id);
if (index < 0) list.add(value); else list.set(index, value);
}
private User lockUser(Long userId) {
// 锁定已存在的用户行,首次创建也有稳定锁对象;不锁定其他用户。
User u = em.find(User.class, userId, LockModeType.PESSIMISTIC_WRITE);
if (u == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
roles.requireStudent(u);
return u;
}
private void checkRevision(long actual, Long expected) {
if (expected == null || actual != expected)
throw new BusinessException(ErrorCode.ONLINE_RESUME_REVISION_CONFLICT);
}
private OnlineResumeView persist(User user, OnlineResume row, OnlineResumeContent content, OnlineResumeContent before) {
content = copy(content);
validate(content);
Set<String> skillNames = new HashSet<>();
for (var skill : content.getSkills()) {
skill.setName(skill.getName().trim());
if (!skillNames.add(skill.getName()))
throw new BusinessException(ErrorCode.BAD_REQUEST, "duplicate skill name");
}
assignIds(content.getEducationExperiences(), before.getEducationExperiences(), OnlineResumeContent.Education::getId, OnlineResumeContent.Education::setId);
assignIds(content.getWorkExperiences(), before.getWorkExperiences(), OnlineResumeContent.Work::getId, OnlineResumeContent.Work::setId);
assignIds(content.getProjectExperiences(), before.getProjectExperiences(), OnlineResumeContent.Project::getId, OnlineResumeContent.Project::setId);
assignIds(content.getSkills(), before.getSkills(), OnlineResumeContent.Skill::getId, OnlineResumeContent.Skill::setId);
String json = encode(content);
if (json.getBytes(StandardCharsets.UTF_8).length > 1024 * 1024)
throw new BusinessException(ErrorCode.BAD_REQUEST, "resume is too large");
rejectControlCharacters(mapper.valueToTree(content));
Long uid = user.getId();
StudentProfile p = profiles.lockByUserId(uid).orElseGet(() -> StudentProfile.builder().userId(uid).build());
var basic = content.getBasicInfo();
p.setRealName(basic.getRealName()); p.setLocation(basic.getLocation());
p.setEducation(basic.getEducation()); p.setSchool(basic.getSchool());
p.setGraduationYear(basic.getGraduationYear() == null ? null : basic.getGraduationYear().shortValue());
p.setBio(content.getSelfEvaluation());
p.setSkillsJson(encode(content.getSkills().stream().map(OnlineResumeContent.Skill::getName).toList()));
// 不触碰 resumeUrl、原始文件名或文件内容;也不改变账号手机号/邮箱。
profiles.save(p);
StudentPreference pref = preferences.findByStudentUserId(uid).orElseGet(() -> StudentPreference.builder().studentUserId(uid).build());
var intention = content.getJobIntention();
pref.setExpectedSalaryMin(intention.getExpectedSalaryMin()); pref.setExpectedSalaryMax(intention.getExpectedSalaryMax());
pref.setPreferredCities(encode(intention.getPreferredCities())); pref.setEmploymentTypes(encode(intention.getEmploymentTypes()));
pref.setPreferredIndustries(encode(intention.getPreferredIndustries())); pref.setPreferredJobTypes(encode(intention.getPreferredJobTypes()));
pref.setRemoteAllowed(intention.isRemoteAllowed()); preferences.save(pref);
if (row == null) { row = new OnlineResume(); row.setUserId(uid); }
row.setContentJson(json); row.setRevision(Math.addExact(row.getRevision(), 1));
row.setUpdatedAt(LocalDateTime.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS)); resumes.saveAndFlush(row);
refresh.requestRefresh(uid, LocalDateTime.now());
return view(user, row);
}
private <T> void assignIds(List<T> next, List<T> previous, Function<T, String> getId,
java.util.function.BiConsumer<T, String> setId) {
Set<String> allowed = new HashSet<>(); previous.forEach(x -> allowed.add(getId.apply(x)));
Set<String> used = new HashSet<>();
for (T value : next) {
String id = getId.apply(value);
if (id == null) { id = UUID.randomUUID().toString(); setId.accept(value, id); }
else if (!allowed.contains(id)) throw new BusinessException(ErrorCode.BAD_REQUEST, "unknown resume entry id");
if (!used.add(id)) throw new BusinessException(ErrorCode.BAD_REQUEST, "duplicate resume entry id");
}
}
private OnlineResumeView view(User user, OnlineResume row) {
OnlineResumeContent c = row == null ? new OnlineResumeContent() : decode(row.getContentJson());
var b = c.getBasicInfo();
if (row == null) { b.setPhone(user.getPhone()); b.setEmail(user.getEmail()); }
StudentProfile p = profiles.findByUserId(user.getId()).orElse(null);
if (p != null) {
b.setRealName(p.getRealName()); b.setLocation(p.getLocation()); b.setSchool(p.getSchool()); b.setEducation(p.getEducation());
b.setGraduationYear(p.getGraduationYear() == null ? null : p.getGraduationYear().intValue());
c.setSelfEvaluation(p.getBio());
// 旧资料接口仍可编辑基础技能;按名字保留在线简历的扩展信息。
List<String> names = legacySkills(p.getSkillsJson());
Map<String, OnlineResumeContent.Skill> details = new LinkedHashMap<>();
c.getSkills().forEach(s -> details.putIfAbsent(s.getName(), s));
List<OnlineResumeContent.Skill> skills = new ArrayList<>();
for (int i = 0; i < names.size(); i++) {
String name = names.get(i);
OnlineResumeContent.Skill s = details.get(name);
if (s == null) { s = new OnlineResumeContent.Skill(); s.setName(name);
s.setId(UUID.nameUUIDFromBytes((user.getId() + ":" + i + ":" + name).getBytes(StandardCharsets.UTF_8)).toString()); }
skills.add(s);
}
c.setSkills(skills);
}
var intention = c.getJobIntention();
preferences.findByStudentUserId(user.getId()).ifPresent(pref -> {
intention.setExpectedSalaryMin(pref.getExpectedSalaryMin()); intention.setExpectedSalaryMax(pref.getExpectedSalaryMax());
intention.setPreferredCities(legacySkills(pref.getPreferredCities())); intention.setEmploymentTypes(legacySkills(pref.getEmploymentTypes()));
intention.setPreferredIndustries(legacySkills(pref.getPreferredIndustries())); intention.setPreferredJobTypes(legacySkills(pref.getPreferredJobTypes()));
intention.setRemoteAllowed(Boolean.TRUE.equals(pref.getRemoteAllowed()));
});
OnlineResumeView.Attachment attachment = p == null || p.getResumeUrl() == null ? null :
new OnlineResumeView.Attachment(p.getResumeOriginalName(), p.getResumeContentType(), p.getResumeSize(), "/api/v1/profile/me/resume");
return new OnlineResumeView(row == null ? 0 : row.getRevision(), c, attachment, row == null ? null : row.getUpdatedAt());
}
private List<String> legacySkills(String json) {
if (json == null || json.isBlank()) return new ArrayList<>();
try {
// H2 的历史 JSON 列可将 Java String 再包装为 JSON 字符串;同时兼容真实 MySQL 数组。
JsonNode node = mapper.readTree(json);
for (int depth = 0; depth < 3 && node != null && node.isTextual(); depth++)
node = mapper.readTree(node.textValue());
if (node == null || node.isNull()) return new ArrayList<>();
if (node != null && node.isArray()) {
List<String> values = new ArrayList<>();
for (JsonNode item : node) {
if (!item.isTextual()) throw new IllegalArgumentException("not a string array");
values.add(item.textValue());
}
return values;
}
} catch (JsonProcessingException | IllegalArgumentException ignored) { /* 兼容历史 [Java, SQL],不静默丢失资料。 */ }
String text = json.trim();
if (text.startsWith("[") && text.endsWith("]")) text = text.substring(1, text.length() - 1);
return new ArrayList<>(Arrays.stream(text.split(",")).map(String::trim).filter(s -> !s.isEmpty()).toList());
}
private void validate(OnlineResumeContent content) {
Set<ConstraintViolation<OnlineResumeContent>> errors = validator.validate(content);
if (!errors.isEmpty()) throw new ConstraintViolationException(errors);
}
private void rejectControlCharacters(JsonNode node) {
if (node.isTextual()) {
if (node.textValue().codePoints().anyMatch(c -> c < 32 && c != 9 && c != 10 && c != 13 || c == 0xfffe || c == 0xffff))
throw new BusinessException(ErrorCode.BAD_REQUEST, "unsupported control character in resume");
} else node.forEach(this::rejectControlCharacters);
}
private String encode(Object value) {
try { return mapper.writeValueAsString(value); }
catch (JsonProcessingException e) { throw new IllegalStateException("cannot encode online resume", e); }
}
private OnlineResumeContent decode(String json) {
try { return mapper.readValue(json, OnlineResumeContent.class); }
catch (JsonProcessingException e) { throw new IllegalStateException("cannot decode online resume", e); }
}
private OnlineResumeContent copy(OnlineResumeContent content) { return decode(encode(content)); }
}

10
mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeView.java

@ -0,0 +1,10 @@
package com.mydreamjob.onlineresume;
import java.time.LocalDateTime;
public record OnlineResumeView(long revision, OnlineResumeContent content,
Attachment attachment, LocalDateTime updatedAt) {
/** 无附件时 attachment=null。文件必须经鉴权下载接口访问。 */
@io.swagger.v3.oas.annotations.media.Schema(name = "OnlineResumeAttachment")
public record Attachment(String fileName, String contentType, Long size, String downloadUrl) {}
}

8
mydreamjob-server/src/main/resources/db/migration/V59__online_resume.sql

@ -0,0 +1,8 @@
-- 在线简历扩展;不改写既有学生资料、偏好或附件。
CREATE TABLE online_resumes (
user_id BIGINT NOT NULL PRIMARY KEY,
revision BIGINT NOT NULL DEFAULT 0,
content_json LONGTEXT NOT NULL,
updated_at DATETIME(6) NOT NULL,
CONSTRAINT fk_online_resumes_user FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

6
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationMigrationTest.java

@ -32,7 +32,7 @@ class ApplicationReapplicationMigrationTest {
s.execute("INSERT INTO applications(id,job_id,student_user_id,status) VALUES(990001,990001,990002,'WITHDRAWN'),(990002,990002,990002,'REJECTED')"); s.execute("INSERT INTO applications(id,job_id,student_user_id,status) VALUES(990001,990001,990002,'WITHDRAWN'),(990002,990002,990002,'REJECTED')");
s.execute("INSERT INTO application_status_history(application_id,operator_user_id,from_status,to_status) VALUES(990001,990002,'SUBMITTED','WITHDRAWN')"); s.execute("INSERT INTO application_status_history(application_id,operator_user_id,from_status,to_status) VALUES(990001,990002,'SUBMITTED','WITHDRAWN')");
s.execute("INSERT INTO application_match_records(application_id,job_id,company_id,candidate_id,status) VALUES(990001,990001,990001,990002,'MATCHED')"); s.execute("INSERT INTO application_match_records(application_id,job_id,company_id,candidate_id,status) VALUES(990001,990001,990001,990002,'MATCHED')");
assertEquals(1,flyway(url,mysql.getUsername(),mysql.getPassword(),null).migrate().migrationsExecuted); assertEquals(1,flyway(url,mysql.getUsername(),mysql.getPassword(),"58").migrate().migrationsExecuted);
assertEquals("WITHDRAWN",scalar(s,"SELECT status FROM applications WHERE id=990001")); assertEquals("WITHDRAWN",scalar(s,"SELECT status FROM applications WHERE id=990001"));
assertEquals("1",scalar(s,"SELECT attempt_number FROM applications WHERE id=990001")); assertEquals("1",scalar(s,"SELECT attempt_number FROM applications WHERE id=990001"));
assertNull(scalar(s,"SELECT active_application_key FROM applications WHERE id=990001")); assertNull(scalar(s,"SELECT active_application_key FROM applications WHERE id=990001"));
@ -46,8 +46,8 @@ class ApplicationReapplicationMigrationTest {
s.execute("INSERT INTO applications(job_id,student_user_id,status,attempt_number,previous_application_id) VALUES(990001,990002,'SUBMITTED',3,990003)"); s.execute("INSERT INTO applications(job_id,student_user_id,status,attempt_number,previous_application_id) VALUES(990001,990002,'SUBMITTED',3,990003)");
assertThrows(SQLException.class,()->s.execute("INSERT INTO applications(job_id,student_user_id,status,attempt_number) VALUES(990001,990002,'WITHDRAWN',2)")); assertThrows(SQLException.class,()->s.execute("INSERT INTO applications(job_id,student_user_id,status,attempt_number) VALUES(990001,990002,'WITHDRAWN',2)"));
assertEquals("3",scalar(s,"SELECT COUNT(*) FROM applications WHERE job_id=990001")); assertEquals("3",scalar(s,"SELECT COUNT(*) FROM applications WHERE job_id=990001"));
flyway(url,mysql.getUsername(),mysql.getPassword(),null).validate(); flyway(url,mysql.getUsername(),mysql.getPassword(),"58").validate();
assertEquals(0,flyway(url,mysql.getUsername(),mysql.getPassword(),null).migrate().migrationsExecuted); assertEquals(0,flyway(url,mysql.getUsername(),mysql.getPassword(),"58").migrate().migrationsExecuted);
} }
} }
} }

4
mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java

@ -503,8 +503,8 @@ class CompanyResumeDownloadIntegrationTest {
} }
assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13); assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13);
assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18); assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18);
assertThat(pathCount - modulePaths).as("188 paths 包含首次密码设置、聊天补拉及简历消息").isEqualTo(188); assertThat(pathCount - modulePaths).as("192 paths 包含首次密码设置、聊天补拉、简历消息及在线简历").isEqualTo(192);
assertThat(opCount - moduleOperations).as("227 operations 包含首次密码设置、聊天补拉及简历消息").isEqualTo(227); assertThat(opCount - moduleOperations).as("235 operations 包含首次密码设置、聊天补拉、简历消息及在线简历").isEqualTo(235);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse();

8
mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java

@ -52,8 +52,8 @@ public final class UnifiedIdentityJarAcceptanceProbe {
JsonNode runtime=call("GET","/v3/api-docs",null,null,200); JsonNode runtime=call("GET","/v3/api-docs",null,null,200);
JsonNode contract=JSON.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); JsonNode contract=JSON.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).isEqualTo(operations(contract)); checks++; assertThat(operations(runtime)).isEqualTo(operations(contract)); checks++;
assertThat(runtime.path("paths").size()).isEqualTo(201); checks++; assertThat(runtime.path("paths").size()).isEqualTo(205); checks++;
assertThat(operations(runtime)).hasSize(245); checks++; assertThat(operations(runtime)).hasSize(253); checks++;
JsonNode pending=login("19900009602"); JsonNode pending=login("19900009602");
assertThat(pending.path("role").asText()).isEqualTo("UNASSIGNED"); checks++; assertThat(pending.path("role").asText()).isEqualTo("UNASSIGNED"); checks++;
assertThat(pending.path("identitySelectionRequired").asBoolean()).isTrue(); checks++; assertThat(pending.path("identitySelectionRequired").asBoolean()).isTrue(); checks++;
@ -125,9 +125,9 @@ public final class UnifiedIdentityJarAcceptanceProbe {
} }
Files.writeString(Path.of("target/identity-probe-result.json"),JSON.writeValueAsString(Map.of( Files.writeString(Path.of("target/identity-probe-result.json"),JSON.writeValueAsString(Map.of(
"checks",checks,"failures",0,"mysql","8.4","redis","7.4","rabbitmq","3", "checks",checks,"failures",0,"mysql","8.4","redis","7.4","rabbitmq","3",
"paths",201,"operations",245,"smsDelivery","mock sender; not real provider", "paths",205,"operations",253,"smsDelivery","mock sender; not real provider",
"fixtureResidual",0,"auditPolicy","append-only; isolated container lifecycle"))); "fixtureResidual",0,"auditPolicy","append-only; isolated container lifecycle")));
System.out.println("Unified identity latest-JAR acceptance PASS: "+checks+" checks; 201 paths/245 operations; fixture residual 0"); System.out.println("Unified identity latest-JAR acceptance PASS: "+checks+" checks; 205 paths/253 operations; fixture residual 0");
} finally {stop();} } finally {stop();}
} }
} }

2
mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java

@ -210,7 +210,7 @@ class UnifiedRegistrationIntegrationTest {
} }
} }
assertThat(moduleDenied).as("新模块全部拒绝未选择身份账号").isEqualTo(18); assertThat(moduleDenied).as("新模块全部拒绝未选择身份账号").isEqualTo(18);
assertThat(denied - moduleDenied).as("既有身份门禁、事件补拉及简历消息均受保护").isEqualTo(201); assertThat(denied - moduleDenied).as("既有身份门禁、事件补拉、简历消息及新增8项在线简历操作均受保护").isEqualTo(209);
java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-identity-gate-result.json"), java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-identity-gate-result.json"),
"{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}"); "{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}");
} }

3
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java

@ -113,6 +113,9 @@ class GlobalExceptionHandlerStatusContractTest {
java.util.Map.entry("SALARY_IDEMPOTENCY_CONFLICT", HttpStatus.CONFLICT), java.util.Map.entry("SALARY_IDEMPOTENCY_CONFLICT", HttpStatus.CONFLICT),
java.util.Map.entry("SALARY_POLICY_VERSION_CONFLICT", HttpStatus.CONFLICT), java.util.Map.entry("SALARY_POLICY_VERSION_CONFLICT", HttpStatus.CONFLICT),
java.util.Map.entry("RESUME_MESSAGE_UNAVAILABLE", HttpStatus.CONFLICT), java.util.Map.entry("RESUME_MESSAGE_UNAVAILABLE", HttpStatus.CONFLICT),
// 在线简历:版本冲突 409/6002,条目不存在 404/6003;此处独立维护,不复用生产映射。
java.util.Map.entry("ONLINE_RESUME_REVISION_CONFLICT", HttpStatus.CONFLICT),
java.util.Map.entry("ONLINE_RESUME_ENTRY_NOT_FOUND", HttpStatus.NOT_FOUND),
java.util.Map.entry("SALARY_INVALID_PARAMETER", HttpStatus.BAD_REQUEST)); java.util.Map.entry("SALARY_INVALID_PARAMETER", HttpStatus.BAD_REQUEST));
private HttpStatus expectedStatus(ErrorCode errorCode) { private HttpStatus expectedStatus(ErrorCode errorCode) {

8
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java

@ -34,10 +34,10 @@ class ErrorHttpStatusRegistryTest {
@Test @Test
@DisplayName("Phase B:显式映射包含企业未认证拒绝状态") @DisplayName("Phase B:显式映射包含企业未认证拒绝状态")
void explicitMappingCountMatchesBaseline() { void explicitMappingCountMatchesBaseline() {
assertThat(registry.explicitMappingCount()).isEqualTo(134); assertThat(registry.explicitMappingCount()).isEqualTo(136);
// 123 个常量只覆盖 122 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与 // 123 个常量只覆盖 122 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与
// DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。 // DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。
assertThat(registry.explicitNumericCodeCount()).isEqualTo(133); assertThat(registry.explicitNumericCodeCount()).isEqualTo(135);
assertThat(registry.resolve(ErrorCode.COMPANY_NOT_VERIFIED)).isEqualTo(HttpStatus.FORBIDDEN); assertThat(registry.resolve(ErrorCode.COMPANY_NOT_VERIFIED)).isEqualTo(HttpStatus.FORBIDDEN);
} }
@ -123,12 +123,12 @@ class ErrorHttpStatusRegistryTest {
.isInstanceOf(UnsupportedOperationException.class); .isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN)) assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN))
.isInstanceOf(UnsupportedOperationException.class); .isInstanceOf(UnsupportedOperationException.class);
assertThat(view).hasSize(134); assertThat(view).hasSize(136);
Map<Integer, HttpStatus> numeric = ErrorHttpStatusRegistry.numericMappingsView(); Map<Integer, HttpStatus> numeric = ErrorHttpStatusRegistry.numericMappingsView();
assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK)) assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK))
.isInstanceOf(UnsupportedOperationException.class); .isInstanceOf(UnsupportedOperationException.class);
assertThat(numeric).hasSize(133); assertThat(numeric).hasSize(135);
} }
@Test @Test

6
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java

@ -97,6 +97,8 @@ class GlobalExceptionHandlerMappingParityTest {
new GlobalExceptionHandler(new RequestIdAccessor(), registry); new GlobalExceptionHandler(new RequestIdAccessor(), registry);
private static final java.util.Map<String, HttpStatus> MODULE_ADDITIONS = java.util.Map.ofEntries( private static final java.util.Map<String, HttpStatus> MODULE_ADDITIONS = java.util.Map.ofEntries(
java.util.Map.entry("ONLINE_RESUME_REVISION_CONFLICT", HttpStatus.CONFLICT),
java.util.Map.entry("ONLINE_RESUME_ENTRY_NOT_FOUND", HttpStatus.NOT_FOUND),
java.util.Map.entry("ONBOARDING_CHECKLIST_ITEM_NOT_FOUND", HttpStatus.NOT_FOUND), java.util.Map.entry("ONBOARDING_CHECKLIST_ITEM_NOT_FOUND", HttpStatus.NOT_FOUND),
java.util.Map.entry("SALARY_CITY_NOT_FOUND", HttpStatus.NOT_FOUND), java.util.Map.entry("SALARY_CITY_NOT_FOUND", HttpStatus.NOT_FOUND),
java.util.Map.entry("SALARY_HISTORY_NOT_FOUND", HttpStatus.NOT_FOUND), java.util.Map.entry("SALARY_HISTORY_NOT_FOUND", HttpStatus.NOT_FOUND),
@ -132,8 +134,8 @@ class GlobalExceptionHandlerMappingParityTest {
@Test @Test
@DisplayName("B2:显式映射集合与重构前快照逐项相等") @DisplayName("B2:显式映射集合与重构前快照逐项相等")
void explicitMappingSetEqualsPreRefactorSnapshot() { void explicitMappingSetEqualsPreRefactorSnapshot() {
assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(134); assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(136);
assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(133); assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(135);
for (ErrorCode errorCode : ErrorCode.values()) { for (ErrorCode errorCode : ErrorCode.values()) {
HttpStatus expected = baselineStatus(errorCode); HttpStatus expected = baselineStatus(errorCode);

10
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java

@ -50,6 +50,8 @@ class R01BIndependentHttpMappingParityTest {
Map.of("RESUME_MESSAGE_UNAVAILABLE", HttpStatus.CONFLICT); Map.of("RESUME_MESSAGE_UNAVAILABLE", HttpStatus.CONFLICT);
private static final java.util.Map<String, HttpStatus> MODULE_ADDITIONS = java.util.Map.ofEntries( private static final java.util.Map<String, HttpStatus> MODULE_ADDITIONS = java.util.Map.ofEntries(
java.util.Map.entry("ONLINE_RESUME_REVISION_CONFLICT", HttpStatus.CONFLICT),
java.util.Map.entry("ONLINE_RESUME_ENTRY_NOT_FOUND", HttpStatus.NOT_FOUND),
java.util.Map.entry("ONBOARDING_CHECKLIST_ITEM_NOT_FOUND", HttpStatus.NOT_FOUND), java.util.Map.entry("ONBOARDING_CHECKLIST_ITEM_NOT_FOUND", HttpStatus.NOT_FOUND),
java.util.Map.entry("SALARY_CITY_NOT_FOUND", HttpStatus.NOT_FOUND), java.util.Map.entry("SALARY_CITY_NOT_FOUND", HttpStatus.NOT_FOUND),
java.util.Map.entry("SALARY_HISTORY_NOT_FOUND", HttpStatus.NOT_FOUND), java.util.Map.entry("SALARY_HISTORY_NOT_FOUND", HttpStatus.NOT_FOUND),
@ -107,7 +109,7 @@ class R01BIndependentHttpMappingParityTest {
// HEAD 基线有 169 条;后续阶段新增码单独列出,不修改任何历史基线行。 // HEAD 基线有 169 条;后续阶段新增码单独列出,不修改任何历史基线行。
assertThat(base).hasSize(169); assertThat(base).hasSize(169);
assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK"); assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK");
assertThat(ErrorCode.values()).hasSize(189); assertThat(ErrorCode.values()).hasSize(191);
int checked = 0; int checked = 0;
for (ErrorCode code : ErrorCode.values()) { for (ErrorCode code : ErrorCode.values()) {
@ -130,7 +132,7 @@ class R01BIndependentHttpMappingParityTest {
.isEqualTo(expected[1]); .isEqualTo(expected[1]);
checked++; checked++;
} }
assertThat(checked).isEqualTo(189); assertThat(checked).isEqualTo(191);
} }
@Test @Test
@ -142,10 +144,10 @@ class R01BIndependentHttpMappingParityTest {
assertThat(explicitInBaseline).isEqualTo(115); assertThat(explicitInBaseline).isEqualTo(115);
assertThat(base.size() - explicitInBaseline).isEqualTo(54); assertThat(base.size() - explicitInBaseline).isEqualTo(54);
assertThat(registry.explicitMappingCount()).isEqualTo(134); assertThat(registry.explicitMappingCount()).isEqualTo(136);
assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(55); assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(55);
// 123 个显式常量覆盖 122 个唯一数字码(9103 共用)。 // 123 个显式常量覆盖 122 个唯一数字码(9103 共用)。
assertThat(registry.explicitNumericCodeCount()).isEqualTo(133); assertThat(registry.explicitNumericCodeCount()).isEqualTo(135);
} }
@Test @Test

47
mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java

@ -376,6 +376,12 @@ class R01EIndependentCrossDatabaseVerificationTest {
userId, realName, bio, location, education, school, graduationYear, skills, t, t); userId, realName, bio, location, education, school, graduationYear, skills, t, t);
} }
/** 在线简历扩展数据;用于验证导出按当前用户读取一次且不混入他人内容。 */
private static void insertOnlineResume(JdbcTemplate j, long userId, String realName, LocalDateTime t) {
j.update("INSERT INTO online_resumes (user_id, revision, content_json, updated_at) VALUES (?, ?, ?, ?)",
userId, 1L, "{\"basicInfo\":{\"realName\":\"" + realName + "\"}}", t);
}
private static long insertJob(JdbcTemplate j, long companyId, String title, LocalDateTime t) { private static long insertJob(JdbcTemplate j, long companyId, String title, LocalDateTime t) {
j.update("INSERT INTO jobs (company_id, title, description, city, employment_type, status, " j.update("INSERT INTO jobs (company_id, title, description, city, employment_type, status, "
+ "created_at, updated_at) VALUES (?, ?, '描述', '北京', 'FULL_TIME', 'PUBLISHED', ?, ?)", + "created_at, updated_at) VALUES (?, ?, '描述', '北京', 'FULL_TIME', 'PUBLISHED', ?, ?)",
@ -514,6 +520,7 @@ class R01EIndependentCrossDatabaseVerificationTest {
j.update("DELETE FROM resume_versions WHERE user_id IN " + u); j.update("DELETE FROM resume_versions WHERE user_id IN " + u);
j.update("DELETE FROM student_preferences WHERE student_user_id IN " + u); j.update("DELETE FROM student_preferences WHERE student_user_id IN " + u);
j.update("DELETE FROM student_profiles WHERE user_id IN " + u); j.update("DELETE FROM student_profiles WHERE user_id IN " + u);
j.update("DELETE FROM online_resumes WHERE user_id IN " + u);
j.update("DELETE FROM applications WHERE student_user_id IN " + u); j.update("DELETE FROM applications WHERE student_user_id IN " + u);
j.update("DELETE FROM jobs WHERE company_id IN " + myCompanies); j.update("DELETE FROM jobs WHERE company_id IN " + myCompanies);
j.update("DELETE FROM companies WHERE owner_user_id IN " + u); j.update("DELETE FROM companies WHERE owner_user_id IN " + u);
@ -763,16 +770,19 @@ class R01EIndependentCrossDatabaseVerificationTest {
} }
@Test @Test
@DisplayName("E1:collector 输出不含其他用户数据") @DisplayName("E1:collector 输出不含其他用户数据(含在线简历)")
void e1CollectorOutputExcludesOtherUsers() { void e1CollectorOutputExcludesOtherUsers() {
Map<String, Long> ids = seedFixture(h2Jdbc); Map<String, Long> ids = seedFixture(h2Jdbc);
seedFixture(MySql.jdbc); seedFixture(MySql.jdbc);
insertOnlineResume(h2Jdbc, ids.get("me"), "本人线上简历", at(4));
insertOnlineResume(h2Jdbc, ids.get("outsider"), "外人线上简历", at(5));
String dump = canonicalResult(h2Collector.collect(ids.get("me"))); String dump = canonicalResult(h2Collector.collect(ids.get("me")));
dump("e1-no-foreign.txt", dump); dump("e1-no-foreign.txt", dump);
assertThat(dump).as("本人线上简历必须进入导出").contains("online-resume").contains("本人线上简历");
for (String foreign : List.of("李四", "外人的简介", "外人简历", "外人求职信", "外人评价", "外人通知", for (String foreign : List.of("李四", "外人的简介", "外人简历", "外人求职信", "外人评价", "外人通知",
"外人会话", "out-m1", "外人Offer", "外人理由", "外人面试")) { "外人会话", "out-m1", "外人Offer", "外人理由", "外人面试", "外人线上简历")) {
assertThat(dump).as("他人数据不得混入导出:%s", foreign).doesNotContain(foreign); assertThat(dump).as("他人数据不得混入导出:%s", foreign).doesNotContain(foreign);
} }
// 关联数据:外人 id 不得以其任何形式出现 // 关联数据:外人 id 不得以其任何形式出现
@ -780,7 +790,7 @@ class R01EIndependentCrossDatabaseVerificationTest {
} }
@Test @Test
@DisplayName("E1:1 / 10 / 50 条投递的真实 JDBC 语句数恒为 20/20/20(与实施报告同基线,两侧实测)") @DisplayName("E1:1 / 10 / 50 条投递的真实 JDBC 语句数恒为 21/21/21(含在线简历一次用户主键查询,两侧实测)")
void e1QueryBudgetIsFlatOnBothEngines() { void e1QueryBudgetIsFlatOnBothEngines() {
int[] sizes = {1, 10, 50}; int[] sizes = {1, 10, 50};
int[] onH2 = new int[3]; int[] onH2 = new int[3];
@ -801,15 +811,29 @@ class R01EIndependentCrossDatabaseVerificationTest {
dump("e1-budget.txt", "minimalFixture h2=" + java.util.Arrays.toString(onH2) dump("e1-budget.txt", "minimalFixture h2=" + java.util.Arrays.toString(onH2)
+ " mysql=" + java.util.Arrays.toString(onMy) + "\n"); + " mysql=" + java.util.Arrays.toString(onMy) + "\n");
assertThat(onH2).as("E1 H2:SQL 数必须精确为 20/20/20").containsExactly(20, 20, 20); assertThat(onH2).as("E1 H2:SQL 数必须精确为 21/21/21").containsExactly(21, 21, 21);
assertThat(onMy).as("E1 MySQL:SQL 数必须精确为 20/20/20").containsExactly(20, 20, 20); assertThat(onMy).as("E1 MySQL:SQL 数必须精确为 21/21/21").containsExactly(21, 21, 21);
// 排除「测试准备 / 清理」SQL:计数在夹具灌入完成之后才 reset, // 排除「测试准备 / 清理」SQL:计数在夹具灌入完成之后才 reset,
// 故上述 20 条全部来自 collect(),且全部是 SELECT // 故上述 21 条全部来自 collect(),且全部是 SELECT
assertThat(h2SqlFor1).as("H2 侧实测语句必须全部是 collect() 发出的查询").hasSize(20) assertThat(h2SqlFor1).as("H2 侧实测语句必须全部是 collect() 发出的查询").hasSize(21)
.allMatch(sql -> sql.trim().toUpperCase().startsWith("SELECT")); .allMatch(sql -> sql.trim().toUpperCase().startsWith("SELECT"));
assertThat(mySqlFor1).as("MySQL 侧实测语句必须全部是 collect() 发出的查询").hasSize(20) assertThat(mySqlFor1).as("MySQL 侧实测语句必须全部是 collect() 发出的查询").hasSize(21)
.allMatch(sql -> sql.trim().toUpperCase().startsWith("SELECT")); .allMatch(sql -> sql.trim().toUpperCase().startsWith("SELECT"));
// 在线简历按当前用户主键读取一次;两次夹具中同时存在他人记录,用于证明过滤条件生效。
assertOnlineResumeQueryOnce(h2SqlFor1, "H2");
assertOnlineResumeQueryOnce(mySqlFor1, "MySQL");
}
private static void assertOnlineResumeQueryOnce(List<String> sqls, String engine) {
List<String> online = sqls.stream()
.filter(sql -> sql.toLowerCase().contains("online_resumes"))
.toList();
assertThat(online).as("%s:online_resumes 只允许读取一次", engine).hasSize(1);
String sql = online.get(0).toLowerCase();
assertThat(sql).as("%s:在线简历查询必须受 user_id 条件约束", engine)
.contains("user_id").contains("?");
} }
@Test @Test
@ -834,7 +858,8 @@ class R01EIndependentCrossDatabaseVerificationTest {
/** /**
* 与实施报告同基线的最小夹具:仅 me/other 两个用户、一家公司、N 个岗位与 N 条投递。 * 与实施报告同基线的最小夹具:仅 me/other 两个用户、一家公司、N 个岗位与 N 条投递。
* 用于直接对账报告中「1/10/50 → 20/20/20」的结论。 * 用于对账本批更新后的「1/10/50 → 21/21/21」:原 20 条查询 + 1 条当前用户在线简历主键查询。
* 变更依据见 docs/online-resume-r01e-query-budget-20261020.md。
*/ */
private Map<String, Integer> measureE1Minimal(int size) { private Map<String, Integer> measureE1Minimal(int size) {
return measureE1(size, false); return measureE1(size, false);
@ -877,6 +902,9 @@ class R01EIndependentCrossDatabaseVerificationTest {
ids.put("other", other); ids.put("other", other);
ids.put("companyOwner", ids.put("companyOwner",
j.queryForObject("SELECT id FROM companies WHERE owner_user_id = ?", Long.class, other)); j.queryForObject("SELECT id FROM companies WHERE owner_user_id = ?", Long.class, other));
// 两条在线简历同时存在:collector 只允许按当前用户主键读取一次,且不得导出他人内容。
insertOnlineResume(j, me, "本人最小线上简历", at(2));
insertOnlineResume(j, other, "他人最小线上简历", at(3));
return ids; return ids;
} }
@ -1554,6 +1582,7 @@ class R01EIndependentCrossDatabaseVerificationTest {
"SELECT COUNT(*) FROM conversation_members WHERE user_id IN " + users, "SELECT COUNT(*) FROM conversation_members WHERE user_id IN " + users,
"SELECT COUNT(*) FROM conversations WHERE created_by IN " + users, "SELECT COUNT(*) FROM conversations WHERE created_by IN " + users,
"SELECT COUNT(*) FROM applications WHERE student_user_id IN " + users, "SELECT COUNT(*) FROM applications WHERE student_user_id IN " + users,
"SELECT COUNT(*) FROM online_resumes WHERE user_id IN " + users,
"SELECT COUNT(*) FROM jobs WHERE title LIKE '" + PREFIX + "%'", "SELECT COUNT(*) FROM jobs WHERE title LIKE '" + PREFIX + "%'",
"SELECT COUNT(*) FROM companies WHERE name LIKE '" + PREFIX + "%'", "SELECT COUNT(*) FROM companies WHERE name LIKE '" + PREFIX + "%'",
"SELECT COUNT(*) FROM notifications WHERE user_id IN " + users)) { "SELECT COUNT(*) FROM notifications WHERE user_id IN " + users)) {

1
mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataCollectorQueryBudgetTest.java

@ -35,6 +35,7 @@ import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class PersonalDataCollectorQueryBudgetTest { class PersonalDataCollectorQueryBudgetTest {
@org.mockito.Mock private com.mydreamjob.onlineresume.OnlineResumeRepository onlineResumeRepository;
@Mock UserRepository userRepository; @Mock UserRepository userRepository;
@Mock StudentProfileRepository studentProfileRepository; @Mock StudentProfileRepository studentProfileRepository;

4
mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java

@ -148,8 +148,8 @@ class LxqModulesMySql57IntegrationTest {
JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs")) JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray());
JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).hasSize(245).isEqualTo(operations(contract)); assertThat(operations(runtime)).hasSize(253).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(201); assertThat(runtime.path("paths").size()).isEqualTo(205);
assertThat(runtime.path("paths").path("/conversations/{id}/events")) assertThat(runtime.path("paths").path("/conversations/{id}/events"))
.isEqualTo(contract.path("paths").path("/conversations/{id}/events")); .isEqualTo(contract.path("paths").path("/conversations/{id}/events"));
for (String field : Set.of("clientMessageId", "version")) { for (String field : Set.of("clientMessageId", "version")) {

4
mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java

@ -59,8 +59,8 @@ class LxqModulesMySqlIntegrationTest {
JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs")) JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray());
JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).hasSize(245).isEqualTo(operations(contract)); assertThat(operations(runtime)).hasSize(253).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(201); assertThat(runtime.path("paths").size()).isEqualTo(205);
assertThat(runtime.path("paths").path("/conversations/{id}/events")) assertThat(runtime.path("paths").path("/conversations/{id}/events"))
.isEqualTo(contract.path("paths").path("/conversations/{id}/events")); .isEqualTo(contract.path("paths").path("/conversations/{id}/events"));
for (String field : Set.of("clientMessageId", "version")) { for (String field : Set.of("clientMessageId", "version")) {

232
mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeIntegrationTest.java

@ -0,0 +1,232 @@
package com.mydreamjob.onlineresume;
import com.fasterxml.jackson.databind.*;
import com.mydreamjob.auth.*;
import com.mydreamjob.profile.*;
import com.mydreamjob.preference.*;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.*;
import org.springframework.http.MediaType;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.zip.ZipInputStream;
import java.io.ByteArrayInputStream;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("integration-test")
@org.springframework.test.context.TestPropertySource(properties = "spring.datasource.url=jdbc:h2:mem:online_resume;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
class OnlineResumeIntegrationTest {
private static final String PATH = "/profile/me/online-resume";
private static final AtomicLong PHONE = new AtomicLong(19907000000L);
@Autowired MockMvc mvc;
@Autowired ObjectMapper mapper;
@Autowired UserRepository users;
@Autowired AuthSessionService sessions;
@Autowired StudentProfileRepository profiles;
@Autowired StudentPreferenceRepository preferences;
@Autowired OnlineResumeRepository resumes;
@Autowired OnlineResumeService service;
@Autowired com.mydreamjob.dataexport.PersonalDataCollector collector;
@Autowired com.mydreamjob.deletion.AccountDeletionRepository deletions;
@Autowired com.mydreamjob.deletion.AccountDeletionCompletionWorker deletionWorker;
@MockBean RedisTemplate<String, Object> redis;
User user; String token;
@BeforeEach void setup() {
user = createUser(User.Role.STUDENT); token = bearer(user);
}
User createUser(User.Role role) {
return users.save(User.builder().phone(Long.toString(PHONE.incrementAndGet())).email("synthetic@example.test")
.role(role).roles(Set.of(role)).active(true).build());
}
String bearer(User u) {
return "Bearer " + sessions.createSession(u, java.time.Instant.now(), new com.mydreamjob.audit.AuditContext()).accessToken();
}
String legacySkillFixture() { return "[Java, SQL]"; }
JsonNode data(ResultActions action) throws Exception {
return mapper.readTree(action.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8)).path("data");
}
JsonNode read() throws Exception { return data(mvc.perform(get(PATH).header("Authorization", token))); }
OnlineResumeContent content() {
OnlineResumeContent c = new OnlineResumeContent(); c.getBasicInfo().setRealName("合成测试");
c.getBasicInfo().setPhone("13800000000"); c.getBasicInfo().setEmail("resume@example.test");
c.setSelfEvaluation("合成自我评价 <>&"); c.getJobIntention().setTargetPosition("后端工程师");
c.getJobIntention().setJobSeekingStatus(OnlineResumeContent.JobSeekingStatus.LOOKING);
c.getJobIntention().setEmploymentStatus(OnlineResumeContent.EmploymentStatus.UNEMPLOYED);
c.getJobIntention().setAvailableFrom(LocalDate.of(2026,11,1));
c.getJobIntention().setExpectedSalaryMin(10000); c.getJobIntention().setExpectedSalaryMax(20000);
c.getJobIntention().setPreferredCities(List.of("测试城市"));
var e = new OnlineResumeContent.Education(); e.setSchool("合成大学");e.setDegree("本科");e.setMajor("计算机");
e.setStartDate(LocalDate.of(2018,9,1));e.setEndDate(LocalDate.of(2022,6,1));c.getEducationExperiences().add(e);
var w = new OnlineResumeContent.Work();w.setCompany("合成企业");w.setPosition("工程师");w.setStartDate(LocalDate.of(2022,7,1));w.setOngoing(true);c.getWorkExperiences().add(w);
var p = new OnlineResumeContent.Project();p.setName("合成项目");p.setRole("开发");p.setStartDate(LocalDate.of(2023,1,1));p.setOngoing(true);c.getProjectExperiences().add(p);
var s = new OnlineResumeContent.Skill();s.setName("Java");s.setLevel(OnlineResumeContent.SkillLevel.PROFICIENT);s.setYears(3);c.getSkills().add(s);
return c;
}
ResultActions save(long revision, OnlineResumeContent c) throws Exception {
return mvc.perform(put(PATH).header("Authorization",token).contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsBytes(new OnlineResumeSaveRequest(revision,c))));
}
@Test void emptyReadDoesNotCreateRowsAndDefaultsContacts() throws Exception {
JsonNode d=read();assertEquals(0,d.path("revision").asLong());
assertEquals(user.getPhone(),d.at("/content/basicInfo/phone").asText());
assertEquals(user.getEmail(),d.at("/content/basicInfo/email").asText());
assertFalse(resumes.existsById(user.getId()));assertFalse(profiles.existsByUserId(user.getId()));
}
@Test void savesAllSectionsAndFreshSessionReadsPersistedState() throws Exception {
JsonNode saved=data(save(0,content()));assertEquals(1,saved.path("revision").asLong());
for(String s:List.of("educationExperiences","workExperiences","projectExperiences","skills")) assertFalse(saved.at("/content/"+s+"/0/id").asText().isBlank());
token=bearer(user);assertEquals(saved,read());
assertEquals(user.getPhone(),users.findById(user.getId()).orElseThrow().getPhone());
assertEquals(user.getEmail(),users.findById(user.getId()).orElseThrow().getEmail());
assertEquals("合成测试",profiles.findByUserId(user.getId()).orElseThrow().getRealName());
JsonNode legacySkills=mapper.readTree(profiles.findByUserId(user.getId()).orElseThrow().getSkillsJson());
if(legacySkills.isTextual())legacySkills=mapper.readTree(legacySkills.asText());
assertEquals(mapper.readTree("[\"Java\"]"),legacySkills);
assertEquals(10000,preferences.findByStudentUserId(user.getId()).orElseThrow().getExpectedSalaryMin());
}
@Test void existingProfilePreferencesAndAttachmentAreIncludedAndPreserved() throws Exception {
profiles.save(StudentProfile.builder().userId(user.getId()).realName("既有姓名").bio("既有简介").skillsJson(legacySkillFixture())
.resumeUrl(user.getId()+"_abcdef01.docx").resumeOriginalName("合成.docx").resumeContentType("application/test").resumeSize(123L).build());
preferences.save(StudentPreference.builder().studentUserId(user.getId()).preferredCities("[\"测试\"]").remoteAllowed(true).build());
JsonNode d=read();assertEquals(2,d.at("/content/skills").size());assertEquals("既有简介",d.at("/content/selfEvaluation").asText());
assertEquals("合成.docx",d.at("/attachment/fileName").asText());assertEquals(123,d.at("/attachment/size").asInt());
assertTrue(d.at("/content/jobIntention/remoteAllowed").asBoolean());
data(save(0,content()));assertEquals(user.getId()+"_abcdef01.docx",profiles.findByUserId(user.getId()).orElseThrow().getResumeUrl());
}
@Test void staleSaveIsConflictAndLeavesAllSectionsUntouched() throws Exception {
JsonNode first=data(save(0,content()));var stale=content();stale.getBasicInfo().setRealName("不能覆盖");
save(0,stale).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value(6002));assertEquals(first,read());
}
@Test void fullReplacementCanClearListsContactsAndLegacyFields() throws Exception {
data(save(0,content()));JsonNode d=data(save(1,new OnlineResumeContent()));
assertEquals(0,d.at("/content/workExperiences").size());assertTrue(d.at("/content/basicInfo/phone").isMissingNode()||d.at("/content/basicInfo/phone").isNull());
assertNull(profiles.findByUserId(user.getId()).orElseThrow().getRealName());
assertNull(preferences.findByStudentUserId(user.getId()).orElseThrow().getExpectedSalaryMin());
}
@Test void educationWorkProjectSkillCrudUsesSharedRevision() throws Exception {
var c=content();long revision=0;
for(var item:Map.of("education-experiences",c.getEducationExperiences().get(0),"work-experiences",c.getWorkExperiences().get(0),
"project-experiences",c.getProjectExperiences().get(0),"skills",c.getSkills().get(0)).entrySet()) {
String path=PATH+"/"+item.getKey();
JsonNode added=data(mvc.perform(post(path).header("Authorization",token).contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsBytes(Map.of("revision",revision,"entry",item.getValue())))));
revision=added.path("revision").asLong();
JsonNode list=data(mvc.perform(get(path).header("Authorization",token)));String id=list.get(0).path("id").asText();assertFalse(id.isBlank());
assertEquals(list.get(0),data(mvc.perform(get(path+"/"+id).header("Authorization",token))));
JsonNode updated=data(mvc.perform(put(path+"/"+id).header("Authorization",token).contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsBytes(Map.of("revision",revision,"entry",item.getValue())))));revision=updated.path("revision").asLong();
data(mvc.perform(delete(path+"/"+id).param("revision",Long.toString(revision)).header("Authorization",token)));revision++;
mvc.perform(get(path+"/"+id).header("Authorization",token)).andExpect(status().isNotFound());
}
assertEquals(12,read().path("revision").asLong());
}
@Test void authAndOtherAccountIsolation() throws Exception {
mvc.perform(get(PATH)).andExpect(status().isUnauthorized());
for(var role:List.of(User.Role.COMPANY,User.Role.ADMIN)) mvc.perform(get(PATH).header("Authorization",bearer(createUser(role)))).andExpect(status().isForbidden());
data(save(0,content()));User other=createUser(User.Role.STUDENT);
JsonNode d=data(mvc.perform(get(PATH).param("userId",user.getId().toString()).header("Authorization",bearer(other))));
assertEquals(0,d.path("revision").asLong());assertEquals(other.getPhone(),d.at("/content/basicInfo/phone").asText());
assertEquals(0,d.at("/content/workExperiences").size());
String id=read().at("/content/workExperiences/0/id").asText();
mvc.perform(delete(PATH+"/work-experiences/"+id).param("revision","0").header("Authorization",bearer(other))).andExpect(status().isNotFound());
assertEquals(1,read().at("/content/workExperiences").size());
}
@Test void invalidDatesContactsSalaryAndNullEntriesAreRejectedAtomically() throws Exception {
var c=content();c.getBasicInfo().setEmail("invalid");save(0,c).andExpect(status().isBadRequest());
c=content();c.getBasicInfo().setPhone("abc");save(0,c).andExpect(status().isBadRequest());
c=content();c.getJobIntention().setExpectedSalaryMin(30000);save(0,c).andExpect(status().isBadRequest());
c=content();c.getEducationExperiences().get(0).setEndDate(LocalDate.of(2010,1,1));save(0,c).andExpect(status().isBadRequest());
c=content();c.getWorkExperiences().get(0).setEndDate(LocalDate.now());save(0,c).andExpect(status().isBadRequest());
c=content();c.getSkills().add(null);save(0,c).andExpect(status().isBadRequest());
assertFalse(resumes.existsById(user.getId()));assertFalse(profiles.existsByUserId(user.getId()));
}
@Test void unknownAndDuplicateIdsRejectedWithoutLosingExistingData() throws Exception {
var c=content();c.getWorkExperiences().get(0).setId(UUID.randomUUID().toString());save(0,c).andExpect(status().isBadRequest());
var saved=data(save(0,content()));c=mapper.treeToValue(saved.path("content"),OnlineResumeContent.class);
c.getWorkExperiences().add(c.getWorkExperiences().get(0));save(1,c).andExpect(status().isBadRequest());assertEquals(saved,read());
}
@Test void listLimitsAndXmlControlCharactersAreRejected() throws Exception {
var c=content();for(int i=0;i<31;i++)c.getWorkExperiences().add(c.getWorkExperiences().get(0));save(0,c).andExpect(status().isBadRequest());
c=content();c.setSelfEvaluation("bad\u0000text");save(0,c).andExpect(status().isBadRequest());
c=content();c.getSkills().add(c.getSkills().get(0));save(0,c).andExpect(status().isBadRequest());
assertFalse(resumes.existsById(user.getId()));
}
@Test void downloadIsValidEscapedDocxAndNeverReadsAttachment() throws Exception {
data(save(0,content()));byte[] bytes=mvc.perform(get(PATH+"/download").header("Authorization",token)).andExpect(status().isOk())
.andExpect(header().string("Cache-Control","no-store")).andReturn().getResponse().getContentAsByteArray();
Map<String,String> files=new HashMap<>();try(var zip=new ZipInputStream(new ByteArrayInputStream(bytes),StandardCharsets.UTF_8)){
java.util.zip.ZipEntry e;while((e=zip.getNextEntry())!=null)files.put(e.getName(),new String(zip.readAllBytes(),StandardCharsets.UTF_8));}
assertTrue(files.containsKey("[Content_Types].xml"));assertTrue(files.containsKey("_rels/.rels"));
String xml=files.get("word/document.xml");assertTrue(xml.contains("合成企业"));assertTrue(xml.contains("&lt;&gt;&amp;"));
var factory=javax.xml.parsers.DocumentBuilderFactory.newInstance();factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl",true);
factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
String destination=System.getProperty("qsp.onlineResume.docxOutput");
if(destination!=null)java.nio.file.Files.write(java.nio.file.Path.of(destination),bytes);
}
@Test void concurrentFirstSaveHasOneWinner() throws Exception {
var pool=Executors.newFixedThreadPool(2);var start=new CountDownLatch(1);
try {
List<Future<Boolean>> futures=new ArrayList<>();
for(int i=0;i<2;i++) futures.add(pool.submit(()->{assertTrue(start.await(5,TimeUnit.SECONDS));
try{service.save(user.getId(),new OnlineResumeSaveRequest(0L,content()));return true;}
catch(com.mydreamjob.common.exception.BusinessException e){assertEquals(6002,e.getErrorCodeInt());return false;}}));
start.countDown();int winners=0;for(var f:futures)if(f.get(30,TimeUnit.SECONDS))winners++;
assertEquals(1,winners);assertEquals(1,read().path("revision").asLong());
}finally{pool.shutdownNow();}
}
@Test void personalDataExportIncludesOnlyOwnOnlineResume() throws Exception {
data(save(0,content()));User other=createUser(User.Role.STUDENT);
var otherContent=content();otherContent.getBasicInfo().setRealName("其他合成账号");
service.save(other.getId(),new OnlineResumeSaveRequest(0L,otherContent));
var exported=collector.collect(user.getId()).getSections();
assertTrue(exported.containsKey("online-resume"));
String serialized=mapper.writeValueAsString(exported.get("online-resume"));
assertTrue(serialized.contains("合成测试"));assertFalse(serialized.contains("其他合成账号"));
}
@Test void accountDeletionPurgesOnlyOwnOnlineResume() throws Exception {
data(save(0,content()));User other=createUser(User.Role.STUDENT);
service.save(other.getId(),new OnlineResumeSaveRequest(0L,content()));
var past=java.time.LocalDateTime.now().minusDays(1);
deletions.save(com.mydreamjob.deletion.AccountDeletion.builder().userId(user.getId())
.status(com.mydreamjob.deletion.DeletionStatus.PENDING).requestedAt(past.minusDays(30)).effectiveAt(past).version(0L).build());
assertTrue(deletionWorker.completeIfExpired(user.getId()));
assertFalse(resumes.existsById(user.getId()));assertTrue(resumes.existsById(other.getId()));
}
@Test void legacyProfileUpdatesAreReflectedAndSkillDetailsAreRetained() throws Exception {
data(save(0,content()));
mvc.perform(put("/profile/me").header("Authorization",token).contentType(MediaType.APPLICATION_JSON)
.content("{\"realName\":\"既有接口更新\",\"skills\":[\"Java\",\"SQL\"]}")).andExpect(status().isOk());
JsonNode d=read();assertEquals("既有接口更新",d.at("/content/basicInfo/realName").asText());
assertEquals(2,d.at("/content/skills").size());assertEquals("PROFICIENT",d.at("/content/skills/0/level").asText());
}
@Test void runtimeOpenApiDocumentsOnlineResumeAndPrivateContactFields() throws Exception {
String json=mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
JsonNode doc=mapper.readTree(json);
assertTrue(doc.path("paths").has(PATH));assertTrue(doc.path("paths").has(PATH+"/download"));
JsonNode basic=doc.at("/components/schemas/OnlineResumeBasicInfo/properties");
assertTrue(basic.has("phone"));assertTrue(basic.has("email"));
JsonNode entry=doc.at("/components/schemas/OnlineResumeEntryRequest/properties/entry");
assertFalse(entry.has("$ref"));assertEquals(4,entry.path("oneOf").size());
Set<String> variants=new HashSet<>();entry.path("oneOf").forEach(v->variants.add(v.path("$ref").asText()));
assertEquals(4,variants.size());
for(String name:List.of("OnlineResumeEducation","OnlineResumeWork","OnlineResumeProject","OnlineResumeSkill"))
assertFalse(doc.at("/components/schemas/"+name+"/additionalProperties").asBoolean(true));
JsonNode candidate=doc.at("/components/schemas/CandidateDetailView/properties");
assertFalse(candidate.has("phone"));assertFalse(candidate.has("email"));
String destination=System.getProperty("qsp.onlineResume.openapiOutput");
if(destination!=null)java.nio.file.Files.writeString(java.nio.file.Path.of(destination),json,StandardCharsets.UTF_8);
}
}

50
mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeMigrationTest.java

@ -0,0 +1,50 @@
package com.mydreamjob.onlineresume;
import com.mydreamjob.config.MySql57MigrationConfiguration;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.MySQLContainer;
import java.sql.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class OnlineResumeMigrationTest {
@Test void mysql57V58ToV59() throws Exception { verify("mysql:5.7.36"); }
@Test void mysql8V58ToV59() throws Exception { verify("mysql:8.4"); }
private Flyway flyway(MySQLContainer<?> db, String target) {
var config=Flyway.configure().dataSource(db.getJdbcUrl(),db.getUsername(),db.getPassword()).locations("classpath:db/migration");
if(target!=null)config.target(target);MySql57MigrationConfiguration.configure(config);return config.load();
}
private void verify(String image) throws Exception {
try(var db=new MySQLContainer<>(image).withDatabaseName("online_resume_migration").withUsername("resume").withPassword("isolated-only")
.withUrlParam("characterEncoding","UTF-8")
.withCreateContainerCmdModifier(cmd->cmd.getHostConfig().withMemory(768L*1024*1024))) {
db.start();
try(var c=DriverManager.getConnection(db.getJdbcUrl(),db.getUsername(),db.getPassword());var s=c.createStatement()) {
s.execute("ALTER DATABASE online_resume_migration CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
}
flyway(db,"58").migrate();
try(var c=DriverManager.getConnection(db.getJdbcUrl(),db.getUsername(),db.getPassword());var s=c.createStatement()) {
s.execute("INSERT INTO users(id,phone,role,is_active,is_verified) VALUES(910001,'19907111111','STUDENT',1,1)");
s.execute("INSERT INTO student_profiles(user_id,real_name,resume_url) VALUES(910001,'synthetic','910001_abcdef01.docx')");
List<String> before=history(s);
assertEquals(1,flyway(db,null).migrate().migrationsExecuted);
assertEquals(before,history(s));
try(var r=s.executeQuery("SELECT resume_url FROM student_profiles WHERE user_id=910001")){assertTrue(r.next());assertEquals("910001_abcdef01.docx",r.getString(1));}
try(var p=c.prepareStatement("INSERT INTO online_resumes(user_id,revision,content_json,updated_at) VALUES(910001,1,?,CURRENT_TIMESTAMP(6))")) {
p.setString(1,"{\"synthetic\":\""+"中".repeat(30000)+"\"}");assertEquals(1,p.executeUpdate());
}
try(var r=s.executeQuery("SELECT revision,CHAR_LENGTH(content_json) FROM online_resumes WHERE user_id=910001")){assertTrue(r.next());assertEquals(1,r.getLong(1));assertTrue(r.getInt(2)>30000);}
assertThrows(SQLException.class,()->s.execute("INSERT INTO online_resumes(user_id,revision,content_json,updated_at) VALUES(910001,2,'{}',CURRENT_TIMESTAMP)"));
assertThrows(SQLException.class,()->s.execute("INSERT INTO online_resumes(user_id,revision,content_json,updated_at) VALUES(999999,1,'{}',CURRENT_TIMESTAMP)"));
assertEquals(0,flyway(db,null).migrate().migrationsExecuted);flyway(db,null).validate();
}
}
}
private List<String> history(Statement s) throws SQLException {
List<String> rows=new ArrayList<>();
try(var r=s.executeQuery("SELECT installed_rank,version,checksum,success FROM flyway_schema_history WHERE CAST(version AS UNSIGNED)<=58 ORDER BY installed_rank")){
while(r.next())rows.add(r.getInt(1)+"|"+r.getString(2)+"|"+r.getString(3)+"|"+r.getBoolean(4));
}return rows;
}
}

25
mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeMySqlIntegrationTest.java

@ -0,0 +1,25 @@
package com.mydreamjob.onlineresume;
import org.springframework.test.context.*;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.*;
/** 同一套真实业务断言在 MySQL 中执行,含持久化、并发与账号隔离。 */
@ActiveProfiles(value = "mysql-it", inheritProfiles = false)
@TestPropertySource(properties = {
"app.jwt.access-secret=test-access-secret-key-for-integration-tests-only-not-for-production",
"app.jwt.refresh-secret=test-refresh-secret-key-for-integration-tests-only-not-for-production",
"app.jwt.token-hash-secret=test-token-hash-secret-key-for-integration-tests-only-not-for-production"})
@Testcontainers(disabledWithoutDocker = false)
@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS)
class OnlineResumeMySqlIntegrationTest extends OnlineResumeIntegrationTest {
@Override String legacySkillFixture() { return "[\"Java\",\"SQL\"]"; }
@Container static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("online_resume_test").withUsername("resume").withPassword("isolated-only")
.withCreateContainerCmdModifier(cmd -> cmd.getHostConfig().withMemory(768L * 1024 * 1024));
@DynamicPropertySource static void database(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", MYSQL::getJdbcUrl);
r.add("spring.datasource.username", MYSQL::getUsername);
r.add("spring.datasource.password", MYSQL::getPassword);
}
}
Loading…
Cancel
Save