diff --git a/docs/online-resume-frontend-handover-20261020.md b/docs/online-resume-frontend-handover-20261020.md new file mode 100644 index 0000000..2f44a4b --- /dev/null +++ b/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 视觉渲染。未提交、推送、部署或运行线上迁移。 diff --git a/docs/online-resume-r01e-query-budget-20261020.md b/docs/online-resume-r01e-query-budget-20261020.md new file mode 100644 index 0000000..101d9e0 --- /dev/null +++ b/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 保留为当时批次的实测记录,不回溯改写。 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 2bb6300..04b586a 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -198,6 +198,10 @@ }, { "name": "行业技能词云" + }, + { + "name": "在线简历", + "description": "仅当前求职者本人的非 AI 在线简历" } ], "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": { @@ -20890,6 +21177,507 @@ "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": { diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index c461817..ccfe250 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json' d = json.load(open(p, encoding='utf-8')) 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'] @@ -719,7 +719,7 @@ print(' S03 company resume download contract validated; candidate DTOs remain r # 统计 operation 总数并校验当前接口契约基线。 operations = sum(len(methods) for methods in d['paths'].values()) 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 {'get', 'delete'} <= paths['/conversations/{conversationId}/messages/{messageId}/resume'].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'): assert _option in paths['/jobs/filters']['get']['description'] 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("All checks passed!") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index 31dd641..f8f9774 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java @@ -41,6 +41,8 @@ public enum ErrorCode { // 学生资料 6xxx 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 JOB_NOT_FOUND(7001, "job not found"), diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java index 0858167..def627e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java +++ b/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_NOT_FOUND, ErrorCode.PROFILE_NOT_FOUND, + ErrorCode.ONLINE_RESUME_ENTRY_NOT_FOUND, ErrorCode.REPORT_NOT_FOUND, ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND, ErrorCode.RESUME_VERSION_NOT_FOUND, @@ -149,6 +150,7 @@ public final class ErrorHttpStatusRegistry { ErrorCode.USER_NOT_FOUND, ErrorCode.VERIFICATION_NOT_FOUND) .put(HttpStatus.CONFLICT, + ErrorCode.ONLINE_RESUME_REVISION_CONFLICT, ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE, ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED, ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT, diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java index bd17491..d6a2f27 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java @@ -90,6 +90,7 @@ public class PersonalDataCollector { private final CandidateScoringAppealHistoryRepository candidateScoringAppealHistoryRepository; private final CompanyRepository companyRepository; private final ResumeStorageService resumeStorageService; + private final com.mydreamjob.onlineresume.OnlineResumeRepository onlineResumeRepository; @Getter public static class CollectResult { @@ -102,6 +103,9 @@ public class PersonalDataCollector { List applications = applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(userId); r.getSections().put("profile", buildProfile(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); buildApplications(r, applications); buildInterviews(r, userId); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java index 1f7102a..8a8b8af 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java @@ -40,6 +40,7 @@ public class AccountDeletionCompletionWorker { private final AuthSessionService authSessionService; private final Clock clock; private final com.mydreamjob.messaging.ChatResumeLifecycle chatResumeLifecycle; + private final com.mydreamjob.onlineresume.OnlineResumeRepository onlineResumeRepository; /** * 若用户处于 PENDING 且已到期,则执行完成态迁移 + 脱敏 + 审计。 @@ -73,6 +74,7 @@ public class AccountDeletionCompletionWorker { dataExportCleanupService.purgeForDeletedUser(rec.getUserId()); salaryHistoryCleanupService.purgeForDeletedUser(rec.getUserId()); chatResumeLifecycle.purgeForUser(rec.getUserId()); + onlineResumeRepository.deleteById(rec.getUserId()); // 完成 + 脱敏审计各写一次 auditService.log(userId, "ACCOUNT_DELETION_COMPLETED", "ACCOUNT_DELETION", rec.getId(), "account deletion completed; userId=" + userId diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResume.java b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResume.java new file mode 100644 index 0000000..51517b5 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeContent.java b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeContent.java new file mode 100644 index 0000000..de88799 --- /dev/null +++ b/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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeController.java b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeController.java new file mode 100644 index 0000000..96bba16 --- /dev/null +++ b/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 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 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> list(@PathVariable String section, HttpServletRequest request) { + java.util.List 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 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 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 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 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 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); + }); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeDocxWriter.java b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeDocxWriter.java new file mode 100644 index 0000000..87745dd --- /dev/null +++ b/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 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("") + .append(xml(line)).append(""); + } + body.append(""); + try (var out = new ByteArrayOutputStream(); var zip = new ZipOutputStream(out, StandardCharsets.UTF_8)) { + add(zip, "[Content_Types].xml", ""); + add(zip, "_rels/.rels", ""); + add(zip, "word/document.xml", "" + body + ""); + 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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'"); + } + 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(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeOpenApiConfiguration.java b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeOpenApiConfiguration.java new file mode 100644 index 0000000..714f840 --- /dev/null +++ b/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(); + var unique = new ArrayList(); + for (Schema variant : entry.getOneOf()) + if (variant.get$ref() == null || seen.add(variant.get$ref())) unique.add(variant); + entry.setOneOf(unique); + }; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeRepository.java new file mode 100644 index 0000000..92bce22 --- /dev/null +++ b/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 {} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeSaveRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeSaveRequest.java new file mode 100644 index 0000000..bc3fec8 --- /dev/null +++ b/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) {} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeService.java new file mode 100644 index 0000000..7a793f3 --- /dev/null +++ b/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 void edit(List list, String id, JsonNode json, boolean delete, Class type, + Function getId, java.util.function.BiConsumer 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 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 void assignIds(List next, List previous, Function getId, + java.util.function.BiConsumer setId) { + Set allowed = new HashSet<>(); previous.forEach(x -> allowed.add(getId.apply(x))); + Set 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 names = legacySkills(p.getSkillsJson()); + Map details = new LinkedHashMap<>(); + c.getSkills().forEach(s -> details.putIfAbsent(s.getName(), s)); + List 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 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 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> 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)); } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeView.java b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeView.java new file mode 100644 index 0000000..49de22f --- /dev/null +++ b/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) {} +} diff --git a/mydreamjob-server/src/main/resources/db/migration/V59__online_resume.sql b/mydreamjob-server/src/main/resources/db/migration/V59__online_resume.sql new file mode 100644 index 0000000..e2d52b5 --- /dev/null +++ b/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; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationMigrationTest.java index 81f941b..d2e6b30 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationMigrationTest.java +++ b/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 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')"); - 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("1",scalar(s,"SELECT attempt_number 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)"); 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")); - flyway(url,mysql.getUsername(),mysql.getPassword(),null).validate(); - assertEquals(0,flyway(url,mysql.getUsername(),mysql.getPassword(),null).migrate().migrationsExecuted); + flyway(url,mysql.getUsername(),mysql.getPassword(),"58").validate(); + assertEquals(0,flyway(url,mysql.getUsername(),mysql.getPassword(),"58").migrate().migrationsExecuted); } } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java index a094d39..b0ca81e 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java +++ b/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(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18); - assertThat(pathCount - modulePaths).as("188 paths 包含首次密码设置、聊天补拉及简历消息").isEqualTo(188); - assertThat(opCount - moduleOperations).as("227 operations 包含首次密码设置、聊天补拉及简历消息").isEqualTo(227); + assertThat(pathCount - modulePaths).as("192 paths 包含首次密码设置、聊天补拉、简历消息及在线简历").isEqualTo(192); + assertThat(opCount - moduleOperations).as("235 operations 包含首次密码设置、聊天补拉、简历消息及在线简历").isEqualTo(235); var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java index 635447f..35ab0b0 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java +++ b/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 contract=JSON.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); assertThat(operations(runtime)).isEqualTo(operations(contract)); checks++; - assertThat(runtime.path("paths").size()).isEqualTo(201); checks++; - assertThat(operations(runtime)).hasSize(245); checks++; + assertThat(runtime.path("paths").size()).isEqualTo(205); checks++; + assertThat(operations(runtime)).hasSize(253); checks++; JsonNode pending=login("19900009602"); assertThat(pending.path("role").asText()).isEqualTo("UNASSIGNED"); 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( "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"))); - 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();} } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java index 909348a..46a67d2 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java @@ -210,7 +210,7 @@ class UnifiedRegistrationIntegrationTest { } } 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"), "{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}"); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java index af4cb14..2a32795 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java +++ b/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_POLICY_VERSION_CONFLICT", 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)); private HttpStatus expectedStatus(ErrorCode errorCode) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java index fe77c4f..f36e650 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java @@ -34,10 +34,10 @@ class ErrorHttpStatusRegistryTest { @Test @DisplayName("Phase B:显式映射包含企业未认证拒绝状态") void explicitMappingCountMatchesBaseline() { - assertThat(registry.explicitMappingCount()).isEqualTo(134); + assertThat(registry.explicitMappingCount()).isEqualTo(136); // 123 个常量只覆盖 122 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与 // DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。 - assertThat(registry.explicitNumericCodeCount()).isEqualTo(133); + assertThat(registry.explicitNumericCodeCount()).isEqualTo(135); assertThat(registry.resolve(ErrorCode.COMPANY_NOT_VERIFIED)).isEqualTo(HttpStatus.FORBIDDEN); } @@ -123,12 +123,12 @@ class ErrorHttpStatusRegistryTest { .isInstanceOf(UnsupportedOperationException.class); assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN)) .isInstanceOf(UnsupportedOperationException.class); - assertThat(view).hasSize(134); + assertThat(view).hasSize(136); Map numeric = ErrorHttpStatusRegistry.numericMappingsView(); assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK)) .isInstanceOf(UnsupportedOperationException.class); - assertThat(numeric).hasSize(133); + assertThat(numeric).hasSize(135); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java index 47c033e..4dd70fc 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java @@ -97,6 +97,8 @@ class GlobalExceptionHandlerMappingParityTest { new GlobalExceptionHandler(new RequestIdAccessor(), registry); private static final java.util.Map 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("SALARY_CITY_NOT_FOUND", HttpStatus.NOT_FOUND), java.util.Map.entry("SALARY_HISTORY_NOT_FOUND", HttpStatus.NOT_FOUND), @@ -132,8 +134,8 @@ class GlobalExceptionHandlerMappingParityTest { @Test @DisplayName("B2:显式映射集合与重构前快照逐项相等") void explicitMappingSetEqualsPreRefactorSnapshot() { - assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(134); - assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(133); + assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(136); + assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(135); for (ErrorCode errorCode : ErrorCode.values()) { HttpStatus expected = baselineStatus(errorCode); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java index d4dad39..69919d7 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java @@ -50,6 +50,8 @@ class R01BIndependentHttpMappingParityTest { Map.of("RESUME_MESSAGE_UNAVAILABLE", HttpStatus.CONFLICT); private static final java.util.Map 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("SALARY_CITY_NOT_FOUND", HttpStatus.NOT_FOUND), java.util.Map.entry("SALARY_HISTORY_NOT_FOUND", HttpStatus.NOT_FOUND), @@ -107,7 +109,7 @@ class R01BIndependentHttpMappingParityTest { // HEAD 基线有 169 条;后续阶段新增码单独列出,不修改任何历史基线行。 assertThat(base).hasSize(169); assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK"); - assertThat(ErrorCode.values()).hasSize(189); + assertThat(ErrorCode.values()).hasSize(191); int checked = 0; for (ErrorCode code : ErrorCode.values()) { @@ -130,7 +132,7 @@ class R01BIndependentHttpMappingParityTest { .isEqualTo(expected[1]); checked++; } - assertThat(checked).isEqualTo(189); + assertThat(checked).isEqualTo(191); } @Test @@ -142,10 +144,10 @@ class R01BIndependentHttpMappingParityTest { assertThat(explicitInBaseline).isEqualTo(115); assertThat(base.size() - explicitInBaseline).isEqualTo(54); - assertThat(registry.explicitMappingCount()).isEqualTo(134); + assertThat(registry.explicitMappingCount()).isEqualTo(136); assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(55); // 123 个显式常量覆盖 122 个唯一数字码(9103 共用)。 - assertThat(registry.explicitNumericCodeCount()).isEqualTo(133); + assertThat(registry.explicitNumericCodeCount()).isEqualTo(135); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java index 9e34cf6..2b1c7e4 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java +++ b/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); } + /** 在线简历扩展数据;用于验证导出按当前用户读取一次且不混入他人内容。 */ + 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) { j.update("INSERT INTO jobs (company_id, title, description, city, employment_type, status, " + "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 student_preferences WHERE student_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 jobs WHERE company_id IN " + myCompanies); j.update("DELETE FROM companies WHERE owner_user_id IN " + u); @@ -763,16 +770,19 @@ class R01EIndependentCrossDatabaseVerificationTest { } @Test - @DisplayName("E1:collector 输出不含其他用户数据") + @DisplayName("E1:collector 输出不含其他用户数据(含在线简历)") void e1CollectorOutputExcludesOtherUsers() { Map ids = seedFixture(h2Jdbc); 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"))); dump("e1-no-foreign.txt", dump); + assertThat(dump).as("本人线上简历必须进入导出").contains("online-resume").contains("本人线上简历"); for (String foreign : List.of("李四", "外人的简介", "外人简历", "外人求职信", "外人评价", "外人通知", - "外人会话", "out-m1", "外人Offer", "外人理由", "外人面试")) { + "外人会话", "out-m1", "外人Offer", "外人理由", "外人面试", "外人线上简历")) { assertThat(dump).as("他人数据不得混入导出:%s", foreign).doesNotContain(foreign); } // 关联数据:外人 id 不得以其任何形式出现 @@ -780,7 +790,7 @@ class R01EIndependentCrossDatabaseVerificationTest { } @Test - @DisplayName("E1:1 / 10 / 50 条投递的真实 JDBC 语句数恒为 20/20/20(与实施报告同基线,两侧实测)") + @DisplayName("E1:1 / 10 / 50 条投递的真实 JDBC 语句数恒为 21/21/21(含在线简历一次用户主键查询,两侧实测)") void e1QueryBudgetIsFlatOnBothEngines() { int[] sizes = {1, 10, 50}; int[] onH2 = new int[3]; @@ -801,15 +811,29 @@ class R01EIndependentCrossDatabaseVerificationTest { dump("e1-budget.txt", "minimalFixture h2=" + java.util.Arrays.toString(onH2) + " mysql=" + java.util.Arrays.toString(onMy) + "\n"); - assertThat(onH2).as("E1 H2:SQL 数必须精确为 20/20/20").containsExactly(20, 20, 20); - assertThat(onMy).as("E1 MySQL: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 数必须精确为 21/21/21").containsExactly(21, 21, 21); // 排除「测试准备 / 清理」SQL:计数在夹具灌入完成之后才 reset, - // 故上述 20 条全部来自 collect(),且全部是 SELECT - assertThat(h2SqlFor1).as("H2 侧实测语句必须全部是 collect() 发出的查询").hasSize(20) + // 故上述 21 条全部来自 collect(),且全部是 SELECT + assertThat(h2SqlFor1).as("H2 侧实测语句必须全部是 collect() 发出的查询").hasSize(21) .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")); + + // 在线简历按当前用户主键读取一次;两次夹具中同时存在他人记录,用于证明过滤条件生效。 + assertOnlineResumeQueryOnce(h2SqlFor1, "H2"); + assertOnlineResumeQueryOnce(mySqlFor1, "MySQL"); + } + + private static void assertOnlineResumeQueryOnce(List sqls, String engine) { + List 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 @@ -834,7 +858,8 @@ class R01EIndependentCrossDatabaseVerificationTest { /** * 与实施报告同基线的最小夹具:仅 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 measureE1Minimal(int size) { return measureE1(size, false); @@ -877,6 +902,9 @@ class R01EIndependentCrossDatabaseVerificationTest { ids.put("other", other); ids.put("companyOwner", 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; } @@ -1554,6 +1582,7 @@ class R01EIndependentCrossDatabaseVerificationTest { "SELECT COUNT(*) FROM conversation_members WHERE user_id IN " + users, "SELECT COUNT(*) FROM conversations WHERE created_by 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 companies WHERE name LIKE '" + PREFIX + "%'", "SELECT COUNT(*) FROM notifications WHERE user_id IN " + users)) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataCollectorQueryBudgetTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataCollectorQueryBudgetTest.java index 4369cc4..e2b1ace 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataCollectorQueryBudgetTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataCollectorQueryBudgetTest.java @@ -35,6 +35,7 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class PersonalDataCollectorQueryBudgetTest { + @org.mockito.Mock private com.mydreamjob.onlineresume.OnlineResumeRepository onlineResumeRepository; @Mock UserRepository userRepository; @Mock StudentProfileRepository studentProfileRepository; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java index a790420..8c48740 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java +++ b/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")) .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); - assertThat(operations(runtime)).hasSize(245).isEqualTo(operations(contract)); - assertThat(runtime.path("paths").size()).isEqualTo(201); + assertThat(operations(runtime)).hasSize(253).isEqualTo(operations(contract)); + assertThat(runtime.path("paths").size()).isEqualTo(205); assertThat(runtime.path("paths").path("/conversations/{id}/events")) .isEqualTo(contract.path("paths").path("/conversations/{id}/events")); for (String field : Set.of("clientMessageId", "version")) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java index 57b0cbf..f3b5649 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java +++ b/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")) .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); - assertThat(operations(runtime)).hasSize(245).isEqualTo(operations(contract)); - assertThat(runtime.path("paths").size()).isEqualTo(201); + assertThat(operations(runtime)).hasSize(253).isEqualTo(operations(contract)); + assertThat(runtime.path("paths").size()).isEqualTo(205); assertThat(runtime.path("paths").path("/conversations/{id}/events")) .isEqualTo(contract.path("paths").path("/conversations/{id}/events")); for (String field : Set.of("clientMessageId", "version")) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeIntegrationTest.java new file mode 100644 index 0000000..ccfa2df --- /dev/null +++ b/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 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 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("<>&")); + 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> 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 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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeMigrationTest.java new file mode 100644 index 0000000..80de832 --- /dev/null +++ b/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 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 history(Statement s) throws SQLException { + List 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; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeMySqlIntegrationTest.java new file mode 100644 index 0000000..9a1cfe3 --- /dev/null +++ b/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); + } +}