diff --git a/changes/n03-skill-cloud/data/source-manifest.md b/changes/n03-skill-cloud/data/source-manifest.md new file mode 100644 index 0000000..12b68d9 --- /dev/null +++ b/changes/n03-skill-cloud/data/source-manifest.md @@ -0,0 +1,77 @@ +# N03 行业技能词云来源与覆盖清单 + +## 1. 固定来源 + +| 来源 | 固定版本 | 用途 | 许可/复用条件 | 官方地址与归属 | +| --- | --- | --- | --- | --- | +| ESCO | ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0) | 提供行业/职位映射所需的职业—技能—技能分类主关系、稳定来源 ID 和层级结构 | 依据 Commission Decision 2011/833/EU,可免费下载、使用、复制和复用。文档和产品界面须保留官方署名语句:`This service uses the ESCO classification of the European Commission.` | 数据集下载页:https://esco.ec.europa.eu/en/use-esco/download
复用条件说明:https://esco.ec.europa.eu/en/about-esco/faq | +| O*NET | O*NET 31.0 Database | 为 ESCO 未收录的具体技术提供 `software_skills` 补充来源,并通过职业/工作活动 Importance 提供首版词云热度 `heat` | License 为 CC BY 4.0,归属与许可说明见 https://creativecommons.org/licenses/by/4.0/ | 官方数据库页:https://www.onetcenter.org/database.html | + +运行时不得访问 ESCO、O*NET 或任何在线词库服务。所有首版数据必须随 Flyway 迁移进入本地数据库,并保留来源版本与归属信息。 + +## 2. 首版覆盖边界 + +首版只覆盖互联网、金融、教育三类行业及下列 10 个已列代表性职位。该矩阵定义导入上限和最小覆盖范围,不要求在全量上游数据中导入未列出的职位。 + +| 行业 | 代表性职位 | 首批技能分类范围 | +| --- | --- | --- | +| 互联网 | 前端开发工程师 | 编程语言、Web 基础、前端框架、工程化工具、浏览器与调试、测试、版本控制与协作 | +| 互联网 | 后端开发工程师 | 编程语言、后端框架与平台、数据库、缓存与消息、云/DevOps、测试、工程化工具、安全 | +| 互联网 | 测试工程师 | 编程语言、测试设计、自动化测试框架、接口与数据库测试、性能测试、CI/CD 与工程化工具 | +| 互联网 | DevOps 工程师 | 编程语言与脚本、云平台、容器与编排、CI/CD、配置管理与 IaC、可观测性、安全、数据库 | +| 金融 | 金融数据分析师 | 编程语言、数据分析与可视化、数据库与数据仓库、统计与建模、金融计算框架/平台、金融领域知识、工程化工具 | +| 金融 | 风控建模工程师 | 编程语言、统计与建模、风险模型、建模框架/平台、数据处理与数据库、模型验证、部署与工程化工具 | +| 金融 | 量化开发工程师 | 编程语言、量化建模框架/平台、市场与金融数据、数据库、低延迟与高性能工程、测试、云/DevOps | +| 教育 | 教育产品经理 | 教育领域知识、产品分析方法、需求与流程、数据分析、教育平台与工具、协作与工程化工具 | +| 教育 | 教学设计师 | 教学设计、课程与学习平台、数字化内容工具、评估设计、学习数据分析、无障碍与规范 | +| 教育 | 教育数据分析师 | 编程语言、数据分析与可视化、数据库与 SQL、统计与建模、教育领域知识、报表与工程化工具 | +计数说明:任务给出的逐项职位清单为互联网 4 个、金融 3 个、教育 3 个,共 10 个。验收文字中的“11 个职位”与该枚举不一致;本清单严格覆盖逐项列出的 10 个职位,不自行新增未批准职位。 + +矩阵中的分类是每个职位首批应覆盖的能力域;T3 只导入这些分类下已确定稳定来源 ID、标准技能和中文标准名的技能,不在此文档中展开具体 `INSERT` 行。 + +## 3. 导入规则 + +1. **职责分工**:T2 只建立七张词库表、外键、唯一约束和查询索引;T3 只导入上述三类行业、10 个已列代表性职位及其关联技能分类、技能、别名和 O*NET Importance。 +2. **代表性子集与混合来源**:不导入 ESCO/O*NET 全量数据;ESCO 是职位、通用技能和分类主表;ESCO 无可靠节点的具体技术使用 O*NET `software_skills` 补充;不把平台自由文本岗位或行业直接当作标准词库节点。 +3. **来源 ID 不变**:ESCO/O*NET 来源 ID 原样保存在稳定来源字段中;项目不得重编号、覆盖或伪造上游 ID。 +4. **中文标准名必填**:每个入选技能必须有非空 `name_zh` 中文标准名;中文名称由项目维护,但不替代或修改上游来源 ID。 +5. **热度来源**:每条非空 Importance 必须保存 O*NET SOC、Element ID 和代理标记;技能无法映射 O*NET Importance 时必须保留,写入 `importance=null`;查询时映射为 `heat=null`,并排在有热度技能之后。 +6. **项目映射责任**:项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。行业/职位映射、细粒度分类和跨源技能对应关系均作为项目适配层记录。 +7. **别名归一化**:生成 `normalized_alias` 时依次执行 Unicode NFKC、首尾空白移除、大小写折叠、连字符转空格、连续空白折叠为单空格、句点移除;`+` 和 `#` 必须保留,不能删除或替换。 +8. **来源可追溯**:来源清单写入 `skill_source_versions`,至少记录来源名称、固定版本、许可、归属 URL 和导入时间;O*NET 补充技术的 `source_code` 使用真实 `SOC|Workplace Example|Element ID` 组合,职位技能关系记录 O*NET SOC、Element ID、代理标记及关联的来源版本。 +9. **迁移不可改写**:T2/T3 计划使用 `V35__skill_cloud_taxonomy.sql` 和 `V36__skill_cloud_seed.sql`。执行前若编号已被占用,必须回退规划并刷新编号;后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或任何已执行迁移。 +10. **运行时离线**:接口运行时不得调用外部词库、AI、课程平台或同步服务;排名只读取本地 Flyway 数据。 + +## 4. 来源版本、许可与归属 URL + +| 数据落点 | 来源名称 | 版本字段固定值 | 许可字段固定值 | 归属 URL | +| --- | --- | --- | --- | --- | +| 职业、技能、技能分类及关系 | ESCO | `1.2.1 (Release v5.7.0, Last update 10/12/2025)` | `Commission Decision 2011/833/EU` | https://esco.ec.europa.eu/en/use-esco/download | +| 职位技能 Importance | O*NET | `31.0 Database` | `CC BY 4.0` | https://www.onetcenter.org/database.html | + +ESCO 产品署名必须使用:`This service uses the ESCO classification of the European Commission.` + +## 5. 映射责任边界 + +| 事项 | 上游来源责任 | 项目责任 | +| --- | --- | --- | +| ESCO 职业、技能、分类层级和来源 ID | 固定且可追溯 | 原样保存,不修改 ID | +| O*NET Importance 数值 | 固定且可追溯 | 原样保存;无映射时写 `null`,不造权重 | +| 中文名称和别名 | 不保证完整中文覆盖 | 补齐中文标准名、别名及归一化值 | +| 细粒度技能分类 | 不保证符合本项目展示粒度 | 在职位下维护分类和展示名称 | +| 行业/职位映射 | 不保证覆盖三类项目行业和中文职位名 | 维护行业—职位—分类—技能映射 | +| 跨源技能与重要性关联 | 不提供项目所需的稳定本地关系 | 维护 ESCO 技能到 O*NET `software_skills` 的补充映射,以及每条 Importance 的 SOC/Element ID/代理标记 | + +## 6. 验收检查项 + +- AC-1:`changes/n03-skill-cloud/data/source-manifest.md` 与 `docs/skill-taxonomy-sources.md` 均存在且非空,来源版本、许可、归属 URL、覆盖矩阵和导入规则一致。 +- AC-2:文档明确覆盖互联网、金融、教育三类行业及全部 10 个已列代表性职位,并为每个职位给出首批技能分类范围。 +- AC-3:文档明确固定 ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0)和 O*NET 31.0 Database。 +- AC-4:文档明确 ESCO 依据 Commission Decision 2011/833/EU 可免费下载、使用、复制和复用,并原样保留指定署名语句。 +- AC-5:文档明确 O*NET License 为 CC BY 4.0,并记录官方数据库页与许可 URL。 +- AC-6:文档明确项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。 +- AC-7:文档明确 T2 建结构、T3 导数据,只导入代表性职位和关联技能;技能中文标准名必填。 +- AC-8:文档明确 O*NET 补充技术、每条 Importance 的 SOC/Element ID/代理标记,以及无映射时保留技能并写入 `importance=null`、查询侧映射为 `heat=null`。 +- AC-9:文档明确别名归一化遵循 NFKC、首尾空白、大小写、连字符、连续空白、句点规则,且保留 `+`、`#`。 +- AC-10:文档明确后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或已执行迁移。 +- AC-11:来源、覆盖、导入与责任边界中不得出现未决占位或延期确认语句。 diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 379ae69..c4129ba 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -6,6 +6,7 @@ | 范围 | 2026-09-08核对结果 | 下一动作 | |---|---|---| +| N03/N04/N05 模块集成(2026-10-08 增补) | 隔离集成版本全量 `mvn clean verify` 3224/3224,原仓库转入复验 227/227,F/E/S 均为 0;真实 MySQL 独立功能 9/9、迁移及运行时接口集合 2/2;N05 并发幂等阻断已修复;OpenAPI 196/239 | 待独立提交,随后安排部署和前后端联调;薪资政策数据发布前仍需来源复核。下方历史规划原文保留,不作为当前模块进度依据 | | 核心招聘与账户能力 | 已有代码与历史验收记录;本轮未重跑验收 | 按清单C01–C08做一期集成回归 | | V39–V42 | 已核对提交55e3975、2698d06、5cc2c86、96b3113;不再标为待Git检查点 | 保留回归,不重复开发企业回复/发现/邀约 | | V43 | 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(cf39737) | N01 已关闭版本内小样本补集泄露;30/300/3000 查询预算通过,EXPLAIN 扩容风险保留观察 | diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 7e92da7..be172c1 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -160,8 +160,8 @@ POST /api/v1/auth/logout header: Authorization: Bearer ( | 401 | 401(安全过滤器) | 未登录 / 无 token | | 403 | 2002, 8004, 8303, 8402 | 无角色权限 / 越权(含企业归属不匹配) | | 400 | 1001, 3002-3006, 8404-8408, 8505, 9105 | 参数错误 / 非法请求体 / 资源未命中默认映射 | -| 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 8305, 8501 | 资源不存在 | -| 409 | 5003, 7003, 8001, 8003, 8301-8302, 8304, 8502-8504, 9101, 9103 | 重复 / 非法状态流转 | +| 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 8305, 8501, 10501, 10601, 10603 | 资源不存在 | +| 409 | 5003, 7003, 8001, 8003, 8301-8302, 8304, 8502-8504, 9101, 9103, 10502-10506, 10604-10605 | 重复 / 非法状态流转 / 版本或幂等冲突 | | 500 | 1002, 9001 | 服务器内部错误 | **前端策略**:优先判断 HTTP 状态码,再读取 `body.code` 显示业务信息。 diff --git a/docs/onboarding-api.md b/docs/onboarding-api.md new file mode 100644 index 0000000..cf98af0 --- /dev/null +++ b/docs/onboarding-api.md @@ -0,0 +1,434 @@ +# 入职清单与公司知识介绍 API(N04) + +> 状态:后端实现完成,待真实 MySQL 8.4 / Docker 黑盒验收。 +> +> 数据库迁移:`V50__onboarding_checklist_and_company_knowledge.sql`、`V51__onboarding_standard_checklist_seed.sql`。 + +## 1. Base URL 与认证 + +本地 Base URL: + +```text +http://127.0.0.1:8080/api/v1 +``` + +所有 `/onboarding/**` 接口都要求登录,并携带: + +```http +Authorization: Bearer +Content-Type: application/json +X-Request-Id: +``` + +细粒度角色和所有权在 Service 层校验。除 401 外,接口统一返回 `ApiResponse`: + +```json +{ + "code": 200, + "message": "ok", + "data": {}, + "requestId": "req-...", + "timestamp": "2026-09-17T01:30:00Z" +} +``` + +## 2. 接口与权限总览 + +| # | Method | Path | 角色 | 说明 | +|---|---|---|---|---| +| 1 | GET | `/onboarding/companies/{companyId}/checklist` | STUDENT | 读取有效清单、完成状态和完成率 | +| 2 | PUT | `/onboarding/companies/{companyId}/checklist/items/{itemId}/completion` | STUDENT | 设置本人完成或未完成 | +| 3 | GET | `/onboarding/companies/{companyId}/knowledge` | STUDENT | 读取企业资料、知识内容和公开评价摘要 | +| 4 | GET | `/onboarding/company/checklist-items` | COMPANY | 查询本企业活动与软删除自定义项 | +| 5 | POST | `/onboarding/company/checklist-items` | COMPANY | 创建本企业自定义项 | +| 6 | PUT | `/onboarding/company/checklist-items/{itemId}` | COMPANY | 按版本更新本企业条目 | +| 7 | DELETE | `/onboarding/company/checklist-items/{itemId}?version={version}` | COMPANY | 按版本软删除本企业条目 | +| 8 | GET | `/onboarding/company/knowledge` | COMPANY | 查询本企业知识配置 | +| 9 | PUT | `/onboarding/company/knowledge` | COMPANY | 创建或更新本企业知识配置 | + +权限规则: + +- STUDENT 接口只允许拥有 STUDENT 角色的账号访问。 +- COMPANY 接口只允许拥有 COMPANY 角色且 `companies.verified=true` 的账号访问;未认证企业统一返回 `403/5004`,在读取或写入本企业清单、知识内容前拒绝。 +- COMPANY 接口不接受 `companyId`;服务端从 JWT 用户解析其所属企业,避免跨企业写入。 +- ADMIN 没有 onboarding 专属管理接口;调用 STUDENT/COMPANY 接口时按非目标角色返回 `403/2002`。 +- 学生访问企业清单或知识不要求先有 Offer 或投递记录,但目标企业必须存在。 +- 企业条目不存在或属于其他企业时统一返回 `404/10501`,不泄露其他企业数据。 + +## 3. 入职清单语义 + +### 3.1 通用项与自定义项合并 + +有效清单由以下两部分组成: + +1. 所有活动通用项 `source=STANDARD`;V51 固定写入 12 条。 +2. 当前企业所有活动自定义项 `source=COMPANY`。 + +合并结果按 `sortOrder ASC, id ASC` 稳定排序。企业没有活动自定义项时,`customized=false`,仍返回全部通用项。企业存在活动自定义项时,`customized=true`,返回通用项与企业项。 + +`category` 固定为: + +```text +DOCUMENT | ADMIN | EQUIPMENT | TRAINING | LOGISTICS | OTHER +证件材料 | 行政手续 | 办公设备 | 培训学习 | 到岗安排 | 其他 +``` + +### 3.2 学生读取有效清单 + +```http +GET /api/v1/onboarding/companies/10/checklist +Authorization: Bearer +``` + +响应: + +```json +{ + "code": 200, + "message": "ok", + "data": { + "companyId": 10, + "companyName": "示例科技", + "customized": true, + "totalItems": 13, + "completedItems": 1, + "completionRate": 7.7, + "items": [ + { + "itemId": 101, + "source": "STANDARD", + "itemKey": "standard-id-card", + "title": "准备身份证件", + "description": "准备身份证原件或有效身份证明,用于入职登记。", + "category": "DOCUMENT", + "required": true, + "sortOrder": 100, + "active": true, + "completed": true, + "completedAt": "2026-09-17T09:00:00", + "progressVersion": 1 + }, + { + "itemId": 501, + "source": "COMPANY", + "itemKey": "company-10-8f1c", + "title": "领取门禁卡", + "description": "到行政前台领取门禁卡并完成权限激活。", + "category": "ADMIN", + "required": false, + "sortOrder": 1000, + "active": true, + "completed": false, + "completedAt": null, + "progressVersion": 0 + } + ] + }, + "requestId": "req-onboarding-checklist", + "timestamp": "2026-09-17T01:30:00Z" +} +``` + +`completionRate = round(completedItems * 100 / totalItems, 1)`;总数为 0 时返回 `0.0`。完成状态只属于当前 STUDENT。 + +### 3.3 更新完成状态 + +请求使用绝对状态,而不是切换: + +```http +PUT /api/v1/onboarding/companies/10/checklist/items/501/completion +Authorization: Bearer + +{"completed": true, "version": 0} +``` + +响应: + +```json +{ + "code": 200, + "message": "ok", + "data": { + "itemId": 501, + "completed": true, + "completedAt": "2026-09-17T01:31:00Z", + "progressVersion": 1, + "totalItems": 13, + "completedItems": 2, + "completionRate": 15.4 + }, + "requestId": "req-onboarding-progress", + "timestamp": "2026-09-17T01:31:00Z" +} +``` + +版本规则: + +- 首次保存某条目时可省略 `version` 或传 `0`。 +- 已完成状态变化时必须传当前 `progressVersion`;过期返回 `409/10505`。 +- 重复提交相同 `completed` 值保持幂等,不新增记录、不递增版本。 +- 首次提交 `completed=false` 不创建记录。 +- `completed=false` 会清除 `completedAt`。 +- 一名学生与一个清单项最多一条进度记录。 + +### 3.4 企业创建自定义项 + +```http +POST /api/v1/onboarding/company/checklist-items +Authorization: Bearer + +{ + "title": "领取门禁卡", + "description": "到行政前台领取门禁卡并完成权限激活。", + "category": "ADMIN", + "sortOrder": 1000, + "required": false, + "idempotencyKey": "company-10-access-card-20260917" +} +``` + +响应 `data`: + +```json +{ + "itemId": 501, + "source": "COMPANY", + "itemKey": "company-10-8f1c", + "title": "领取门禁卡", + "description": "到行政前台领取门禁卡并完成权限激活。", + "category": "ADMIN", + "required": false, + "sortOrder": 1000, + "active": true, + "version": 0 +} +``` + +约束: + +- `title`:必填,最多 120 字符。 +- `description`:可选,最多 1000 字符。 +- `category`:必填,仅允许固定枚举。 +- `sortOrder`:可选,默认 `1000`,范围 `0..9999`。 +- `required`:可选,默认 `false`。 +- `idempotencyKey`:可选,最多 64 字符;服务端会 trim,空串按未提供处理。 +- 活动自定义项上限为 50 条;达到上限返回 `409/10503`。 + +幂等重试: + +- 同一企业、同一 `idempotencyKey`、同标题/说明/分类/排序/必填值:返回首次创建的条目。 +- 同一企业、同一 key、请求语义不同:返回 `409/10504`。 +- 不同企业可使用相同 key,互不冲突。 +- 未传 key 时,每次请求创建新条目。 + +### 3.5 企业查询、更新与软删除 + +查询本企业全部条目: + +```http +GET /api/v1/onboarding/company/checklist-items +Authorization: Bearer +``` + +响应包含活动与软删除条目,不返回通用项和其他企业项。更新请求: + +```http +PUT /api/v1/onboarding/company/checklist-items/501 +Authorization: Bearer + +{ + "title": "领取门禁卡并激活", + "description": "到行政前台领取门禁卡并完成权限激活。", + "category": "ADMIN", + "sortOrder": 900, + "required": true, + "active": true, + "version": 0 +} +``` + +更新使用 `version` 乐观锁,成功递增版本;过期返回 `409/10502`。软删除: + +```http +DELETE /api/v1/onboarding/company/checklist-items/501?version=1 +Authorization: Bearer +``` + +首次删除必须使用当前版本,系统只将 `active=false`,不物理删除。重复删除已非活动条目保持幂等成功;接口仍要求携带 `version` 查询参数。 + +## 4. 公司知识介绍语义 + +### 4.1 学生读取与回退 + +```http +GET /api/v1/onboarding/companies/10/knowledge +Authorization: Bearer +``` + +响应: + +```json +{ + "code": 200, + "message": "ok", + "data": { + "company": { + "id": 10, + "name": "示例科技", + "logoUrl": "https://cdn.example.com/company-10.png", + "description": "面向企业提供数据基础设施产品。", + "industry": "互联网", + "website": "https://example.com", + "address": "杭州市余杭区", + "verified": true + }, + "knowledgeState": "CONFIGURED", + "knowledge": { + "culture": "以用户价值为中心,保持透明和长期主义。", + "workPolicies": "弹性上班;每周一、三线下办公。", + "onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。", + "updatedAt": "2026-09-17T01:33:00Z" + }, + "reviewSummary": { + "state": "SUFFICIENT", + "totalReviews": 4, + "averageRating": 4.5, + "ratingDistribution": { + "rating1": 0, + "rating2": 0, + "rating3": 1, + "rating4": 1, + "rating5": 2 + }, + "topTags": ["发展空间", "同事友好"], + "dimensionSummary": { + "AUTHENTICITY": {"averageScore": 4.5, "sampleCount": 4, "dataState": "SUFFICIENT"}, + "BENEFITS": {"averageScore": 4.3, "sampleCount": 4, "dataState": "SUFFICIENT"}, + "SALARY_TRANSPARENCY": {"averageScore": 4.0, "sampleCount": 4, "dataState": "SUFFICIENT"}, + "JD_CLARITY": {"averageScore": 4.6, "sampleCount": 4, "dataState": "SUFFICIENT"}, + "GROWTH": {"averageScore": 4.4, "sampleCount": 4, "dataState": "SUFFICIENT"}, + "TEAM_ATMOSPHERE": {"averageScore": 4.7, "sampleCount": 4, "dataState": "SUFFICIENT"}, + "PROMOTION": {"averageScore": 4.1, "sampleCount": 4, "dataState": "SUFFICIENT"}, + "WORK_LIFE_BALANCE": {"averageScore": 4.0, "sampleCount": 4, "dataState": "SUFFICIENT"} + } + } + }, + "requestId": "req-company-overview", + "timestamp": "2026-09-17T01:34:00Z" +} +``` + +回退与状态: + +- 企业无知识记录:仍返回企业基础资料,`knowledgeState=BASIC_ONLY`,`knowledge` 省略。 +- 企业有知识记录:`knowledgeState=CONFIGURED`,返回非空知识字段和 `updatedAt`;学生公开读取响应不返回知识配置版本。 +- 企业不存在:`404/5001`。 +- 响应白名单不包含 `ownerUserId`、用户 ID、手机号、邮箱、评价哈希或审核原因。 + +评价摘要只聚合企业全部岗位中 `PUBLISHED` 且审核状态为 `AUTO_APPROVED/APPROVED` 的评价: + +| `reviewSummary.state` | 条件 | 返回内容 | +|---|---|---| +| `EMPTY` | 0 条 | `state`、`totalReviews=0` | +| `INSUFFICIENT` | 1–2 条 | `state`、真实 `totalReviews`;不返回均值、分布或维度汇总 | +| `SUFFICIENT` | 至少 3 条 | 平均评分、评分分布、热门标签、八维汇总 | + +热门标签最多 10 个;八个维度分别返回 `sampleCount` 和 `dataState`。维度的 `dataState` 使用同一 3 条阈值。 + +### 4.2 企业查询知识配置 + +```http +GET /api/v1/onboarding/company/knowledge +Authorization: Bearer +``` + +未配置时: + +```json +{ + "code": 200, + "message": "ok", + "data": { + "configured": false, + "version": 0 + }, + "requestId": "req-company-knowledge", + "timestamp": "2026-09-17T01:33:00Z" +} +``` + +已配置时返回 `configured=true`、非空内容、`updatedAt` 和当前 `version`。空字段按 `@JsonInclude(NON_NULL)` 省略。 + +### 4.3 企业创建或更新知识配置 + +```http +PUT /api/v1/onboarding/company/knowledge +Authorization: Bearer + +{ + "culture": "以用户价值为中心,保持透明和长期主义。", + "workPolicies": "弹性上班;每周一、三线下办公。", + "onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。", + "version": 1 +} +``` + +规则: + +- 至少一个内容字段必须非空。 +- `culture` 最多 2000 字符,`workPolicies` 和 `onboardingGuide` 各最多 4000 字符。 +- 字段按纯文本处理,会 trim;包含 HTML 标签时返回 `400/1001`。 +- 首次创建可以省略 `version`,创建后版本为 `0`。 +- 更新已有记录必须提交当前版本;版本过期返回 `409/10506`。 +- 已有内容与提交内容相同且未传版本时按幂等读取处理;内容不同但未传版本返回 `400/1001`。 + +## 5. 错误码 + +| HTTP | code | 含义 | 前端处理 | +|---|---:|---|---| +| 400 | 1001 | 参数、枚举、长度、HTML、缺少必填字段 | 展示字段错误,不自动重试 | +| 401 | 401 | 未登录或 Token 无效 | 清理登录态并重新登录 | +| 403 | 2002 | 角色不匹配、无所属企业或无权限 | 不重试,切换到正确角色 | +| 404 | 5001 | 企业不存在或 COMPANY 无绑定企业 | 检查 `companyId` 或企业绑定状态 | +| 404 | 10501 | 清单项不存在、跨企业或不属于有效清单 | 刷新清单后重新选择条目 | +| 409 | 10502 | 企业清单项版本过期 | 重新 `GET /company/checklist-items`,使用新版本重试 | +| 409 | 10503 | 活动自定义项达到 50 条上限 | 禁用新增,提示先软删除或复用现有项 | +| 409 | 10504 | 幂等键已用于不同请求体 | 更换幂等键,或确认原请求后读取原条目 | +| 409 | 10505 | 学生完成状态版本过期 | 重新读取有效清单并使用新 `progressVersion` | +| 409 | 10506 | 公司知识版本过期 | 重新读取企业知识配置并使用新 `version` | + +401 由安全过滤器返回裸 JSON,没有统一 `ApiResponse.requestId`: + +```json +{"code": 401, "message": "not authenticated", "timestamp": ""} +``` + +其他错误示例: + +```json +{ + "code": 10504, + "message": "idempotency key conflict: request body differs", + "data": null, + "requestId": "req-10504", + "timestamp": "2026-09-17T01:30:00Z" +} +``` + +## 6. 前端推荐流程 + +学生端: + +1. 登录后调用 `GET /onboarding/companies/{companyId}/checklist`。 +2. 使用响应的 `itemId` 和 `progressVersion` 调用完成状态接口。 +3. 收到 `10505` 时重新读取清单,避免覆盖其他设备的新状态。 +4. 调用 `GET /onboarding/companies/{companyId}/knowledge` 展示企业介绍与评价摘要。 +5. 当 `reviewSummary.state=EMPTY/INSUFFICIENT` 时,不展示平均值、分布和维度均值。 + +企业端: + +1. 登录后调用 `GET /onboarding/company/checklist-items` 获取当前版本。 +2. 新增时生成稳定 `idempotencyKey` 并保存到重试请求中。 +3. 更新或删除前使用最新 `version`,冲突后重新拉取列表。 +4. 调用 `GET /onboarding/company/knowledge` 获取配置版本,再使用 `PUT` 保存。 +5. 收到 `10503` 时停止自动新增;收到 `10504` 时不要复用旧请求体。 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index f141cc0..c807a3f 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -186,21 +186,50 @@ { "name": "企业人才邀约", "description": "企业人才邀约接口" + }, + { + "name": "公司知识介绍" + }, + { + "name": "入职清单" + }, + { + "name": "薪资计算器" + }, + { + "name": "行业技能词云" } ], "paths": { "/auth/identity/select": { "post": { - "tags": ["认证"], + "tags": [ + "认证" + ], "summary": "首次选择账号身份", "description": "统一账号一次性选择 STUDENT 或 COMPANY;COMPANY 必须提交 company 资料且注册开关开启。首次成功撤销全部旧会话并返回新凭证;同身份重复请求只返回状态,不重复颁发凭证。", "operationId": "selectIdentity", "requestBody": { - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IdentitySelectionRequest"}}}, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IdentitySelectionRequest" + } + } + }, "required": true }, "responses": { - "200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseIdentitySelectionResponse"}}}} + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseIdentitySelectionResponse" + } + } + } + } } } }, @@ -9495,856 +9524,4111 @@ } } } - } - }, - "components": { - "schemas": { - "IdentitySelectionRequest": { - "type": "object", - "required": ["identity"], - "properties": { - "identity": {"type": "string", "enum": ["STUDENT", "COMPANY"]}, - "company": {"$ref": "#/components/schemas/CompanyDetails"} - } - }, - "IdentitySelectionResponse": { - "type": "object", - "properties": { - "userId": {"type": "integer", "format": "int64"}, - "role": {"type": "string"}, - "identitySelectionRequired": {"type": "boolean"}, - "companyId": {"type": "integer", "format": "int64"}, - "onboardingStatus": {"type": "string"}, - "accessToken": {"type": "string"}, - "refreshToken": {"type": "string"}, - "expiresIn": {"type": "integer", "format": "int64"}, - "reauthenticationRequired": {"type": "boolean"} - } - }, - "ApiResponseIdentitySelectionResponse": { - "type": "object", - "properties": { - "code": {"type": "integer", "format": "int32"}, - "message": {"type": "string"}, - "data": {"$ref": "#/components/schemas/IdentitySelectionResponse"}, - "requestId": {"type": "string"}, - "timestamp": {"type": "string", "format": "date-time"} - } - }, - "TaxonomyResponse": { - "type": "object", - "properties": { - "code": { - "type": "string" + }, + "/onboarding/companies/{companyId}/knowledge": { + "get": { + "tags": [ + "公司知识介绍" + ], + "summary": "读取公司知识介绍", + "description": "STUDENT 读取企业基础资料、企业维护内容和公开评价摘要;企业未配置知识时回退基础资料,公开评价少于 3 条时不返回均值。", + "operationId": "getCompanyKnowledgeOverview", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "companyId", + "in": "path", + "required": true, + "description": "企业 ID", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "查询成功", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponseCompanyKnowledgeOverviewResponse" + }, + "example": { + "code": 200, + "message": "ok", + "data": { + "company": { + "id": 10, + "name": "示例科技", + "logoUrl": "https://cdn.example.com/company-10.png", + "description": "面向企业提供数据基础设施产品。", + "industry": "互联网", + "website": "https://example.com", + "address": "杭州市余杭区", + "verified": true + }, + "knowledgeState": "CONFIGURED", + "knowledge": { + "culture": "以用户价值为中心,保持透明和长期主义。", + "workPolicies": "弹性上班;每周一、三线下办公。", + "onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。", + "updatedAt": "2026-09-17T01:33:00Z" + }, + "reviewSummary": { + "state": "SUFFICIENT", + "totalReviews": 4, + "averageRating": 4.5, + "ratingDistribution": { + "rating1": 0, + "rating2": 0, + "rating3": 1, + "rating4": 1, + "rating5": 2 + }, + "topTags": [ + "发展空间", + "同事友好" + ], + "dimensionSummary": { + "AUTHENTICITY": { + "averageScore": 4.5, + "sampleCount": 4, + "dataState": "SUFFICIENT" + }, + "BENEFITS": { + "averageScore": 4.3, + "sampleCount": 4, + "dataState": "SUFFICIENT" + }, + "SALARY_TRANSPARENCY": { + "averageScore": 4.0, + "sampleCount": 4, + "dataState": "SUFFICIENT" + }, + "JD_CLARITY": { + "averageScore": 4.6, + "sampleCount": 4, + "dataState": "SUFFICIENT" + }, + "GROWTH": { + "averageScore": 4.4, + "sampleCount": 4, + "dataState": "SUFFICIENT" + }, + "TEAM_ATMOSPHERE": { + "averageScore": 4.7, + "sampleCount": 4, + "dataState": "SUFFICIENT" + }, + "PROMOTION": { + "averageScore": 4.1, + "sampleCount": 4, + "dataState": "SUFFICIENT" + }, + "WORK_LIFE_BALANCE": { + "averageScore": 4.0, + "sampleCount": 4, + "dataState": "SUFFICIENT" + } + } + } + }, + "requestId": "req-company-overview", + "timestamp": "2026-09-17T01:34:00Z" + } + } + } }, - "parentCode": { - "type": "string" + "400": { + "description": "companyId 不是正整数", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-1001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } }, - "type": { - "type": "string" + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqUnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } }, - "displayName": { - "type": "string" + "403": { + "description": "仅 STUDENT 角色可访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "404": { + "description": "企业不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 5001, + "message": "company not found", + "data": null, + "requestId": "req-5001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } } } - }, - "RecommendationFeedbackRequest": { - "type": "object", - "required": [ - "feedbackType", - "idempotencyKey", - "expectedVersion" + } + }, + "/salary-calculator/cities/{cityCode}": { + "get": { + "tags": [ + "薪资计算器" ], - "properties": { - "feedbackType": { - "type": "string" + "summary": "查询城市默认参数", + "operationId": "getSalaryCity", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "cityCode", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponseSalaryCityDetailResponse" + } + } + } }, - "reasonType": { - "type": "string" + "401": { + "description": "未认证" }, - "idempotencyKey": { - "type": "string" + "403": { + "description": "非 STUDENT" }, - "expectedVersion": { - "type": "integer", - "format": "int64" + "404": { + "description": "城市不存在或停用" } } - }, - "RecommendationProfileRequest": { - "type": "object", - "required": [ - "industryCode", - "jobFamilyCode", - "enabled", - "expectedVersion" + } + }, + "/salary-calculator/cities": { + "get": { + "tags": [ + "薪资计算器" ], - "properties": { - "industryCode": { - "type": "string" + "summary": "查询启用城市列表", + "operationId": "listSalaryCities", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponseListSalaryCitySummaryResponse" + } + } + } }, - "jobFamilyCode": { - "type": "string" + "401": { + "description": "未认证" }, - "riasecWeights": { - "type": "object", - "additionalProperties": { - "type": "integer" + "403": { + "description": "非 STUDENT" + } + } + } + }, + "/salary-calculator/calculations/{id}": { + "get": { + "tags": [ + "薪资计算器" + ], + "summary": "查询本人薪资历史详情", + "operationId": "getSalaryCalculation", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" } - }, - "abilityExpectations": { - "type": "object", - "additionalProperties": { - "type": "integer" + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponseSalaryHistoryDetailResponse" + } + } } }, - "enabled": { - "type": "boolean" + "401": { + "description": "未认证" }, - "expectedVersion": { - "type": "integer", - "format": "int64" + "403": { + "description": "非 STUDENT" + }, + "404": { + "description": "历史不存在或无权访问" } } }, - "RecommendationDecisionRequest": { - "type": "object", - "required": [ - "decision", - "expectedVersion" + "delete": { + "tags": [ + "薪资计算器" ], - "properties": { - "decision": { + "summary": "删除本人薪资历史", + "operationId": "deleteSalaryCalculation", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponseVoid" + } + } + } + }, + "401": { + "description": "未认证" + }, + "403": { + "description": "非 STUDENT" + }, + "404": { + "description": "历史不存在或无权访问" + } + } + } + }, + "/onboarding/company/checklist-items/{itemId}": { + "put": { + "tags": [ + "入职清单" + ], + "summary": "更新本企业自定义清单项", + "description": "使用当前 version 更新本企业条目;版本过期返回 10502,不静默覆盖。", + "operationId": "updateCompanyChecklistItem", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "itemId", + "in": "path", + "required": true, + "description": "企业自定义清单项 ID", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqCompanyChecklistItemUpdateRequest" + }, + "example": { + "title": "领取门禁卡并激活", + "description": "到行政前台领取门禁卡并完成权限激活。", + "category": "ADMIN", + "sortOrder": 900, + "required": true, + "active": true, + "version": 0 + } + } + } + }, + "responses": { + "200": { + "description": "更新成功并递增 version", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponseCompanyChecklistItemView" + }, + "example": { + "code": 200, + "message": "ok", + "data": { + "itemId": 501, + "source": "COMPANY", + "itemKey": "company-10-8f1c", + "title": "领取门禁卡", + "description": "到行政前台领取门禁卡并完成权限激活。", + "category": "ADMIN", + "required": false, + "sortOrder": 1000, + "active": true, + "version": 0 + }, + "requestId": "req-onboarding-company-item", + "timestamp": "2026-09-17T01:32:00Z" + } + } + } + }, + "400": { + "description": "参数或请求体验证失败", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-1001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqUnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "仅 COMPANY 角色可访问 COMPANY_NOT_VERIFIED: business code 5004; companies.verified must be true.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "404": { + "description": "条目不存在、已不属于该企业,或不属于该企业的有效清单", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 10501, + "message": "onboarding checklist item not found", + "data": null, + "requestId": "req-10501", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "409": { + "description": "请求 version 已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 10502, + "message": "onboarding checklist item was modified concurrently", + "data": null, + "requestId": "req-10502", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + } + } + }, + "delete": { + "tags": [ + "入职清单" + ], + "summary": "软删除本企业自定义清单项", + "description": "使用当前 version 将 active 置为 false;重复删除保持幂等成功。", + "operationId": "deleteCompanyChecklistItem", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "itemId", + "in": "path", + "required": true, + "description": "企业自定义清单项 ID", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "version", + "in": "query", + "required": true, + "description": "客户端最后读取到的条目版本", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "软删除成功;重复删除返回当前非活动条目", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponseCompanyChecklistItemView" + }, + "example": { + "code": 200, + "message": "ok", + "data": { + "itemId": 501, + "source": "COMPANY", + "itemKey": "company-10-8f1c", + "title": "领取门禁卡", + "description": "到行政前台领取门禁卡并完成权限激活。", + "category": "ADMIN", + "required": false, + "sortOrder": 1000, + "active": false, + "version": 1 + }, + "requestId": "req-onboarding-company-item", + "timestamp": "2026-09-17T01:32:00Z" + } + } + } + }, + "400": { + "description": "缺少 version 或参数类型不正确", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-1001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqUnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "仅 COMPANY 角色可访问 COMPANY_NOT_VERIFIED: business code 5004; companies.verified must be true.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "404": { + "description": "条目不存在、已不属于该企业,或不属于该企业的有效清单", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 10501, + "message": "onboarding checklist item not found", + "data": null, + "requestId": "req-10501", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "409": { + "description": "请求 version 已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 10502, + "message": "onboarding checklist item was modified concurrently", + "data": null, + "requestId": "req-10502", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + } + } + } + }, + "/skills/match": { + "get": { + "tags": [ + "行业技能词云" + ], + "summary": "查询个人技能匹配度", + "description": "使用 JWT 对应学生的 skills_json,与当前筛选结果 TOP10 比对;matchScore=round(matchedCount*100/min(10,total))。仅 STUDENT 可访问。", + "operationId": "getSkillMatch", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "industryId", + "in": "query", + "required": true, + "description": "行业 ID,必填正整数", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1, + "example": 1 + } + }, + { + "name": "occupationId", + "in": "query", + "required": true, + "description": "职位 ID,必填正整数;必须属于该行业", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1, + "example": 2 + } + }, + { + "name": "categoryId", + "in": "query", + "required": false, + "description": "技能分类 ID,可选正整数;省略时使用该职位下全部技能", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1, + "example": 101 + } + } + ], + "responses": { + "200": { + "description": "成功返回个人技能匹配结果", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponseSkillMatchResponse" + }, + "example": { + "code": 200, + "message": "ok", + "data": { + "matchScore": 50, + "matchedCount": 1, + "totalCount": 2, + "matchedSkills": [ + { + "rank": 1, + "skillId": 26, + "skillCode": "REACT", + "nameZh": "React", + "nameEn": "React", + "sourceType": "O*NET", + "sourceCode": "15-1254.00|React|2.E.7.h", + "categoryId": 103, + "categoryName": "前端框架", + "heat": 4.38, + "heatSourceCode": "15-1254.00", + "heatElementId": "2.E.7.h", + "heatProxy": true + } + ], + "missingSkills": [ + { + "rank": 2, + "skillId": 31, + "skillCode": "DOCKER", + "nameZh": "Docker", + "nameEn": "Docker", + "sourceType": "O*NET", + "sourceCode": "15-1299.08|Docker|2.E.7.h", + "categoryId": 105, + "categoryName": "工程化工具", + "heat": 4.12, + "heatSourceCode": "15-1299.08", + "heatElementId": "2.E.7.h", + "heatProxy": true + } + ] + }, + "requestId": "req-match", + "timestamp": "2026-09-15T10:01:00Z" + } + } + } + }, + "400": { + "description": "筛选参数缺失、非正整数或节点关系非法", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-1001", + "timestamp": "2026-09-15T10:03:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqUnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "已登录但不是 STUDENT 角色", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-15T10:03:00Z" + } + } + } + } + } + } + }, + "/skills/learning-path": { + "get": { + "tags": [ + "行业技能词云" + ], + "summary": "生成个人学习路径", + "description": "只针对个人 TOP10 中未命中的技能,沿用热度顺序返回连续 priority 和确定性通用建议;不调用 AI,不返回课程链接、时长或进度。仅 STUDENT 可访问。", + "operationId": "getLearningPath", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "industryId", + "in": "query", + "required": true, + "description": "行业 ID,必填正整数", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1, + "example": 1 + } + }, + { + "name": "occupationId", + "in": "query", + "required": true, + "description": "职位 ID,必填正整数;必须属于该行业", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1, + "example": 2 + } + }, + { + "name": "categoryId", + "in": "query", + "required": false, + "description": "技能分类 ID,可选正整数;省略时使用该职位下全部技能", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1, + "example": 101 + } + } + ], + "responses": { + "200": { + "description": "成功返回缺失技能的学习路径;没有缺口时列表为空", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponseSkillLearningPathResponse" + }, + "example": { + "code": 200, + "message": "ok", + "data": { + "total": 1, + "items": [ + { + "priority": 1, + "skillId": 26, + "skillName": "React", + "categoryId": 103, + "categoryName": "前端框架", + "suggestion": "先理解 React 的核心概念与生命周期,再通过一个小型项目练习集成。" + } + ] + }, + "requestId": "req-learning-path", + "timestamp": "2026-09-15T10:02:00Z" + } + } + } + }, + "400": { + "description": "筛选参数缺失、非正整数或节点关系非法", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-1001", + "timestamp": "2026-09-15T10:03:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqUnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "已登录但不是 STUDENT 角色", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-15T10:03:00Z" + } + } + } + } + } + } + }, + "/onboarding/companies/{companyId}/checklist": { + "get": { + "tags": [ + "入职清单" + ], + "summary": "读取有效入职清单", + "description": "STUDENT 读取通用活动项与当前企业活动自定义项的合并清单;无企业自定义项时回退通用清单。", + "operationId": "getEffectiveChecklist", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "companyId", + "in": "path", + "required": true, + "description": "企业 ID", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "查询成功", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponseEffectiveChecklistResponse" + }, + "example": { + "code": 200, + "message": "ok", + "data": { + "companyId": 10, + "companyName": "示例科技", + "customized": true, + "totalItems": 13, + "completedItems": 1, + "completionRate": 7.7, + "items": [ + { + "itemId": 101, + "source": "STANDARD", + "itemKey": "standard-id-card", + "title": "准备身份证件", + "description": "准备身份证原件或有效身份证明,用于入职登记。", + "category": "DOCUMENT", + "required": true, + "sortOrder": 100, + "active": true, + "completed": true, + "completedAt": "2026-09-17T09:00:00", + "progressVersion": 1 + }, + { + "itemId": 501, + "source": "COMPANY", + "itemKey": "company-10-8f1c", + "title": "领取门禁卡", + "description": "到行政前台领取门禁卡并完成权限激活。", + "category": "ADMIN", + "required": false, + "sortOrder": 1000, + "active": true, + "completed": false, + "completedAt": null, + "progressVersion": 0 + } + ] + }, + "requestId": "req-onboarding-checklist", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "400": { + "description": "companyId 不是正整数", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-1001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqUnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "仅 STUDENT 角色可访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "404": { + "description": "企业不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 5001, + "message": "company not found", + "data": null, + "requestId": "req-5001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + } + } + } + }, + "/onboarding/company/knowledge": { + "get": { + "tags": [ + "公司知识介绍" + ], + "summary": "读取本企业知识配置", + "description": "返回当前 COMPANY 所属企业的知识配置;尚未配置时 configured=false、version=0。", + "operationId": "getCompanyKnowledge", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [], + "responses": { + "200": { + "description": "查询成功", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponseCompanyKnowledgeView" + }, + "example": { + "code": 200, + "message": "ok", + "data": { + "configured": true, + "culture": "以用户价值为中心,保持透明和长期主义。", + "workPolicies": "弹性上班;每周一、三线下办公。", + "onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。", + "updatedAt": "2026-09-17T01:33:00Z", + "version": 2 + }, + "requestId": "req-company-knowledge", + "timestamp": "2026-09-17T01:33:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqUnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "仅 COMPANY 角色可访问 COMPANY_NOT_VERIFIED: business code 5004; companies.verified must be true.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "404": { + "description": "当前账号没有所属企业", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 5001, + "message": "company not found", + "data": null, + "requestId": "req-5001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + } + } + }, + "put": { + "tags": [ + "公司知识介绍" + ], + "summary": "创建或更新本企业知识配置", + "description": "首次创建可省略 version;已有配置必须提交当前 version。字段按纯文本保存且禁止 HTML 标签。", + "operationId": "upsertCompanyKnowledge", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqCompanyKnowledgeUpdateRequest" + }, + "example": { + "culture": "以用户价值为中心,保持透明和长期主义。", + "workPolicies": "弹性上班;每周一、三线下办公。", + "onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。", + "version": 1 + } + } + } + }, + "responses": { + "200": { + "description": "创建或更新成功", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponseCompanyKnowledgeView" + }, + "example": { + "code": 200, + "message": "ok", + "data": { + "configured": true, + "culture": "以用户价值为中心,保持透明和长期主义。", + "workPolicies": "弹性上班;每周一、三线下办公。", + "onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。", + "updatedAt": "2026-09-17T01:33:00Z", + "version": 2 + }, + "requestId": "req-company-knowledge", + "timestamp": "2026-09-17T01:33:00Z" + } + } + } + }, + "400": { + "description": "内容为空、超长、包含 HTML 标签,或已有配置缺少 version", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-1001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqUnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "仅 COMPANY 角色可访问 COMPANY_NOT_VERIFIED: business code 5004; companies.verified must be true.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "404": { + "description": "当前账号没有所属企业", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 5001, + "message": "company not found", + "data": null, + "requestId": "req-5001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "409": { + "description": "知识配置版本已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 10506, + "message": "company knowledge was modified concurrently", + "data": null, + "requestId": "req-10506", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + } + } + } + }, + "/salary-calculator/calculations": { + "post": { + "tags": [ + "薪资计算器" + ], + "summary": "计算并保存到手工资", + "operationId": "calculateSalary", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqSalaryCalculationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponseSalaryCalculationResponse" + } + } + } + }, + "400": { + "description": "参数非法" + }, + "401": { + "description": "未认证" + }, + "403": { + "description": "非 STUDENT" + }, + "404": { + "description": "城市不存在或停用" + }, + "409": { + "description": "幂等或政策版本冲突" + } + } + }, + "get": { + "tags": [ + "薪资计算器" + ], + "summary": "查询本人薪资历史", + "operationId": "listSalaryCalculations", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "成功", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponsePageResponseSalaryHistorySummaryResponse" + } + } + } + }, + "400": { + "description": "分页参数非法" + }, + "401": { + "description": "未认证" + }, + "403": { + "description": "非 STUDENT" + } + } + } + }, + "/onboarding/company/checklist-items": { + "get": { + "tags": [ + "入职清单" + ], + "summary": "查询本企业自定义清单项", + "description": "返回当前 COMPANY 所属企业的活动与软删除自定义项,不包含通用项和其他企业项。", + "operationId": "listCompanyChecklistItems", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [], + "responses": { + "200": { + "description": "查询成功;无自定义项时返回空数组", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponseListCompanyChecklistItemView" + }, + "example": { + "code": 200, + "message": "ok", + "data": [ + { + "itemId": 501, + "source": "COMPANY", + "itemKey": "company-10-8f1c", + "title": "领取门禁卡", + "description": "到行政前台领取门禁卡并完成权限激活。", + "category": "ADMIN", + "required": false, + "sortOrder": 1000, + "active": true, + "version": 0 + } + ], + "requestId": "req-onboarding-company-items", + "timestamp": "2026-09-17T01:32:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqUnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "仅 COMPANY 角色可访问 COMPANY_NOT_VERIFIED: business code 5004; companies.verified must be true.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "404": { + "description": "当前账号没有所属企业", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 5001, + "message": "company not found", + "data": null, + "requestId": "req-5001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + } + } + }, + "post": { + "tags": [ + "入职清单" + ], + "summary": "创建本企业自定义清单项", + "description": "为当前 COMPANY 所属企业创建自定义项;idempotencyKey 用于网络重试,活动项累计不超过 50 条。", + "operationId": "createCompanyChecklistItem", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqCompanyChecklistItemCreateRequest" + }, + "example": { + "title": "领取门禁卡", + "description": "到行政前台领取门禁卡并完成权限激活。", + "category": "ADMIN", + "sortOrder": 1000, + "required": false, + "idempotencyKey": "company-10-access-card-20260917" + } + } + } + }, + "responses": { + "200": { + "description": "创建成功;相同幂等请求返回首次创建的条目", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponseCompanyChecklistItemView" + }, + "example": { + "code": 200, + "message": "ok", + "data": { + "itemId": 501, + "source": "COMPANY", + "itemKey": "company-10-8f1c", + "title": "领取门禁卡", + "description": "到行政前台领取门禁卡并完成权限激活。", + "category": "ADMIN", + "required": false, + "sortOrder": 1000, + "active": true, + "version": 0 + }, + "requestId": "req-onboarding-company-item", + "timestamp": "2026-09-17T01:32:00Z" + } + } + } + }, + "400": { + "description": "标题、分类、排序、必填标记或幂等键不符合约束", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-1001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqUnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "仅 COMPANY 角色可访问 COMPANY_NOT_VERIFIED: business code 5004; companies.verified must be true.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "404": { + "description": "当前账号没有所属企业", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 5001, + "message": "company not found", + "data": null, + "requestId": "req-5001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "409": { + "description": "活动自定义项已达到 50 条;或 10504 幂等键已用于不同请求体", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 10503, + "message": "onboarding checklist item limit exceeded", + "data": null, + "requestId": "req-10503", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + } + } + } + }, + "/skills/word-cloud": { + "get": { + "tags": [ + "行业技能词云" + ], + "summary": "查询行业技能词云", + "description": "返回筛选范围内的全部技能,按 heat DESC, nameZh ASC, skillId ASC 排序,heat=null 最后;前端取前十条作为 TOP10。仅 STUDENT 可访问。", + "operationId": "getWordCloud", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "industryId", + "in": "query", + "required": true, + "description": "行业 ID,必填正整数", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1, + "example": 1 + } + }, + { + "name": "occupationId", + "in": "query", + "required": true, + "description": "职位 ID,必填正整数;必须属于该行业", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1, + "example": 2 + } + }, + { + "name": "categoryId", + "in": "query", + "required": false, + "description": "技能分类 ID,可选正整数;省略时返回该职位下全部分类", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1, + "example": 101 + } + } + ], + "responses": { + "200": { + "description": "成功返回筛选范围内的完整技能列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponseSkillCloudResponse" + }, + "example": { + "code": 200, + "message": "ok", + "data": { + "industry": { + "id": 1, + "code": "INTERNET", + "name": "互联网" + }, + "occupation": { + "id": 1, + "code": "FRONTEND_DEVELOPER", + "name": "前端开发工程师" + }, + "category": null, + "total": 1, + "items": [ + { + "rank": 1, + "skillId": 26, + "skillCode": "REACT", + "nameZh": "React", + "nameEn": "React", + "sourceType": "O*NET", + "sourceCode": "15-1254.00|React|2.E.7.h", + "categoryId": 103, + "categoryName": "前端框架", + "heat": 4.38, + "heatSourceCode": "15-1254.00", + "heatElementId": "2.E.7.h", + "heatProxy": true + } + ] + }, + "requestId": "req-word-cloud", + "timestamp": "2026-09-15T10:00:00Z" + } + } + } + }, + "400": { + "description": "筛选参数缺失、非正整数或节点关系非法", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-1001", + "timestamp": "2026-09-15T10:03:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqUnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "已登录但不是 STUDENT 角色", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-15T10:03:00Z" + } + } + } + } + } + } + }, + "/onboarding/companies/{companyId}/checklist/items/{itemId}/completion": { + "put": { + "tags": [ + "入职清单" + ], + "summary": "更新清单条目完成状态", + "description": "STUDENT 使用绝对 completed 状态更新本人进度;已有进度变化时必须提交当前 version,首次创建只接受 version=0 或空。", + "operationId": "updateCompletion", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "companyId", + "in": "path", + "required": true, + "description": "企业 ID", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "itemId", + "in": "path", + "required": true, + "description": "有效清单条目 ID", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqProgressUpdateRequest" + }, + "example": { + "completed": true, + "version": 0 + } + } + } + }, + "responses": { + "200": { + "description": "更新成功;重复提交相同状态返回幂等结果", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponseProgressUpdateResponse" + }, + "example": { + "code": 200, + "message": "ok", + "data": { + "itemId": 501, + "completed": true, + "completedAt": "2026-09-17T01:31:00Z", + "progressVersion": 1, + "totalItems": 13, + "completedItems": 2, + "completionRate": 15.4 + }, + "requestId": "req-onboarding-progress", + "timestamp": "2026-09-17T01:31:00Z" + } + } + } + }, + "400": { + "description": "completed 缺失或 version 为负数", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-1001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqUnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "仅 STUDENT 角色可访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "404": { + "description": "条目不存在、已不属于该企业,或不属于该企业的有效清单", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 10501, + "message": "onboarding checklist item not found", + "data": null, + "requestId": "req-10501", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "409": { + "description": "进度版本已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LxqApiResponse" + }, + "example": { + "code": 10505, + "message": "onboarding progress was modified concurrently", + "data": null, + "requestId": "req-10505", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "IdentitySelectionRequest": { + "type": "object", + "required": [ + "identity" + ], + "properties": { + "identity": { + "type": "string", + "enum": [ + "STUDENT", + "COMPANY" + ] + }, + "company": { + "$ref": "#/components/schemas/CompanyDetails" + } + } + }, + "IdentitySelectionResponse": { + "type": "object", + "properties": { + "userId": { + "type": "integer", + "format": "int64" + }, + "role": { + "type": "string" + }, + "identitySelectionRequired": { + "type": "boolean" + }, + "companyId": { + "type": "integer", + "format": "int64" + }, + "onboardingStatus": { + "type": "string" + }, + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "expiresIn": { + "type": "integer", + "format": "int64" + }, + "reauthenticationRequired": { + "type": "boolean" + } + } + }, + "ApiResponseIdentitySelectionResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/IdentitySelectionResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "TaxonomyResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "parentCode": { + "type": "string" + }, + "type": { + "type": "string" + }, + "displayName": { + "type": "string" + } + } + }, + "RecommendationFeedbackRequest": { + "type": "object", + "required": [ + "feedbackType", + "idempotencyKey", + "expectedVersion" + ], + "properties": { + "feedbackType": { + "type": "string" + }, + "reasonType": { + "type": "string" + }, + "idempotencyKey": { + "type": "string" + }, + "expectedVersion": { + "type": "integer", + "format": "int64" + } + } + }, + "RecommendationProfileRequest": { + "type": "object", + "required": [ + "industryCode", + "jobFamilyCode", + "enabled", + "expectedVersion" + ], + "properties": { + "industryCode": { + "type": "string" + }, + "jobFamilyCode": { + "type": "string" + }, + "riasecWeights": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "abilityExpectations": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "enabled": { + "type": "boolean" + }, + "expectedVersion": { + "type": "integer", + "format": "int64" + } + } + }, + "RecommendationDecisionRequest": { + "type": "object", + "required": [ + "decision", + "expectedVersion" + ], + "properties": { + "decision": { + "type": "string", + "enum": [ + "APPROVE", + "REJECT" + ] + }, + "reasonCode": { + "type": "string" + }, + "expectedVersion": { + "type": "integer", + "format": "int64" + } + } + }, + "RecommendationProfileResponse": { + "type": "object", + "properties": { + "jobId": { + "type": "integer", + "format": "int64" + }, + "industryCode": { + "type": "string" + }, + "jobFamilyCode": { + "type": "string" + }, + "riasecWeights": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "abilityExpectations": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "enabled": { + "type": "boolean" + }, + "reviewStatus": { + "type": "string" + }, + "reviewReason": { + "type": "string" + }, + "version": { + "type": "integer", + "format": "int64" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "RecommendationFeedbackResponse": { + "type": "object", + "properties": { + "jobId": { + "type": "integer", + "format": "int64" + }, + "feedbackType": { + "type": "string" + }, + "reasonType": { + "type": "string" + }, + "status": { + "type": "string" + }, + "version": { + "type": "integer", + "format": "int64" + } + } + }, + "JobRecommendationItem": { + "type": "object", + "properties": { + "jobId": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string" + }, + "city": { + "type": "string" + }, + "employmentType": { + "type": "string" + }, + "salaryMin": { + "type": "integer" + }, + "salaryMax": { + "type": "integer" + }, + "mode": { + "type": "string", + "enum": [ + "PERSONALIZED", + "FALLBACK" + ] + }, + "score": { + "type": "number" + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ApiResponseListTaxonomyResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaxonomyResponse" + } + } + } + }, + "ApiResponseRecommendationFeedbackResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/RecommendationFeedbackResponse" + } + } + }, + "ApiResponseRecommendationProfileResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/RecommendationProfileResponse" + } + } + }, + "ApiResponsePageJobRecommendationItem": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JobRecommendationItem" + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + } + } + }, + "ApiResponsePageRecommendationProfileResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecommendationProfileResponse" + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + } + } + }, + "CompanyReviewJobResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string" + } + } + }, + "CompanyReviewReplyResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "content": { + "type": "string" + }, + "status": { + "type": "string" + }, + "version": { + "type": "integer", + "format": "int64" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "rejectionReason": { + "type": "string", + "nullable": true + } + } + }, + "CompanyReviewItemResponse": { + "type": "object", + "properties": { + "reviewId": { + "type": "integer", + "format": "int64" + }, + "job": { + "$ref": "#/components/schemas/CompanyReviewJobResponse" + }, + "rating": { + "type": "integer" + }, + "content": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "dimensionScores": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "reviewStatus": { + "type": "string" + }, + "reviewCreatedAt": { + "type": "string", + "format": "date-time" + }, + "reply": { + "$ref": "#/components/schemas/CompanyReviewReplyResponse" + } + } + }, + "CompanyReviewDetailResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/CompanyReviewItemResponse" + } + ] + }, + "ReviewReplyPageResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanyReviewItemResponse" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, + "CreateCompanyReviewReplyRequest": { + "type": "object", + "required": [ + "content", + "idempotencyKey" + ], + "properties": { + "content": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "idempotencyKey": { + "type": "string", + "format": "uuid", + "maxLength": 64 + } + } + }, + "UpdateCompanyReviewReplyRequest": { + "type": "object", + "required": [ + "content", + "expectedVersion" + ], + "properties": { + "content": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "expectedVersion": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "ReviewReplyDecisionRequest": { + "type": "object", + "required": [ + "decision" + ], + "properties": { + "decision": { + "type": "string", + "enum": [ + "APPROVE", + "REJECT" + ] + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + }, + "AdminReviewReplyResponse": { + "type": "object", + "properties": { + "replyId": { + "type": "integer", + "format": "int64" + }, + "reviewId": { + "type": "integer", + "format": "int64" + }, + "jobId": { + "type": "integer", + "format": "int64" + }, + "jobTitle": { + "type": "string" + }, + "companyId": { + "type": "integer", + "format": "int64" + }, + "companyName": { + "type": "string" + }, + "rating": { + "type": "integer" + }, + "reviewContent": { + "type": "string" + }, + "reviewModerationStatus": { + "type": "string" + }, + "replyContent": { + "type": "string" + }, + "replyStatus": { + "type": "string" + }, + "version": { + "type": "integer", + "format": "int64" + }, + "riskScore": { + "type": "number" + }, + "riskLevel": { + "type": "string" + }, + "reasonCodes": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "AdminReviewReplyPageResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AdminReviewReplyResponse" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, + "ApiResponseReviewReplyPageResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/ReviewReplyPageResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCompanyReviewDetailResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CompanyReviewDetailResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCompanyReviewReplyResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CompanyReviewReplyResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseAdminReviewReplyPageResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AdminReviewReplyPageResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseAdminReviewReplyResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AdminReviewReplyResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "AccountDeletionResponse": { + "type": "object", + "properties": { + "userId": { + "type": "integer", + "format": "int64", + "example": 2 + }, + "status": { + "type": "string", + "enum": [ + "NONE", + "PENDING", + "COMPLETED" + ], + "example": "PENDING", + "description": "注销状态:NONE/PENDING/COMPLETED" + }, + "requestedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "effectiveAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "cancelledAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "completedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "version": { + "type": "integer", + "format": "int64", + "example": 0 + } + } + }, + "AdminDashboardOverviewResponse": { + "type": "object", + "properties": { + "totalCompanies": { + "type": "integer", + "format": "int32" + }, + "totalJobs": { + "type": "integer", + "format": "int32" + }, + "totalCandidates": { + "type": "integer", + "format": "int32" + }, + "avgCandidateScore": { + "type": "number" + }, + "topCompanies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanyScoreItem" + } + } + } + }, + "ApiResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": {}, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "CareerProgressResponse": { + "type": "object", + "properties": { + "todo": { + "$ref": "#/components/schemas/ProgressTodoSummary" + }, + "applications": { + "$ref": "#/components/schemas/PaginatedApplications" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ProgressTodoSummary": { + "type": "object", + "properties": { + "pendingInterviewResponseCount": { + "type": "integer", + "format": "int64" + }, + "upcomingConfirmedInterviewCount": { + "type": "integer", + "format": "int64" + }, + "pendingOfferResponseCount": { + "type": "integer", + "format": "int64" + }, + "unreadNotificationCount": { + "type": "integer", + "format": "int64" + }, + "unreadReportDecisionCount": { + "type": "integer", + "format": "int64" + } + } + }, + "PaginatedApplications": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CareerProgressItem" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "CareerProgressItem": { + "type": "object", + "properties": { + "applicationId": { + "type": "integer", + "format": "int64" + }, + "applicationStatus": { + "type": "string" + }, + "appliedAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "job": { + "$ref": "#/components/schemas/JobSummary" + }, + "nextInterview": { + "$ref": "#/components/schemas/InterviewSummary" + }, + "latestSubmittedInterviewScore": { + "$ref": "#/components/schemas/ScoreSummary" + }, + "offer": { + "$ref": "#/components/schemas/OfferSummary" + }, + "competition": { + "$ref": "#/components/schemas/CompetitionSummary" + } + } + }, + "JobSummary": { + "type": "object", + "properties": { + "jobId": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string" + }, + "companyName": { + "type": "string" + }, + "city": { + "type": "string" + }, + "employmentType": { + "type": "string" + }, + "jobStatus": { + "type": "string" + } + } + }, + "InterviewSummary": { + "type": "object", + "properties": { + "interviewId": { + "type": "integer", + "format": "int64" + }, + "roundNo": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string" + }, + "interviewType": { + "type": "string" + }, + "startAt": { + "type": "string", + "format": "date-time" + }, + "endAt": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string" + } + } + }, + "ScoreSummary": { + "type": "object", + "properties": { + "interviewId": { + "type": "integer", + "format": "int64" + }, + "overallScore": { + "type": "number" + }, + "recommendation": { + "type": "string" + }, + "submittedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "OfferSummary": { + "type": "object", + "properties": { + "offerId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "expireAt": { + "type": "string", + "format": "date-time" + }, + "onboardingDate": { + "type": "string", + "format": "date" + } + } + }, + "CompetitionSummary": { + "type": "object", + "properties": { + "eligible": { + "type": "boolean" + }, + "rank": { + "type": "integer", + "format": "int32" + }, + "cohortSize": { + "type": "integer", + "format": "int32" + }, + "overallScore": { + "type": "number" + }, + "percentile": { + "type": "number" + }, + "status": { + "type": "string" + }, + "mySnapshotPeriod": { + "type": "string" + }, + "rulesVersion": { + "type": "string" + } + } + }, + "ApiResponseCareerProgressResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "example": 200 + }, + "message": { + "type": "string", + "example": "ok" + }, + "data": { + "$ref": "#/components/schemas/CareerProgressResponse" + }, + "requestId": { + "type": "string", + "example": "req-1" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseAccountDeletionResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "example": 200 + }, + "message": { + "type": "string", + "example": "ok" + }, + "data": { + "$ref": "#/components/schemas/AccountDeletionResponse" + }, + "requestId": { + "type": "string", + "example": "req-1" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseAdminDashboardOverviewResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AdminDashboardOverviewResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", - "enum": [ - "APPROVE", - "REJECT" - ] + "format": "date-time" + } + } + }, + "ApiResponseAppealResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AppealResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateDetailView": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateDetailView" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateFeedbackResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateFeedbackResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateListResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateListResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidatePercentileResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidatePercentileResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateRankingDetailResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateRankingDetailResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateRankingListResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateRankingListResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateScoreCardResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateScoreCardResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateScoreTrendResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateScoreTrendResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateScoringOverviewResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateScoringOverviewResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateScoringTrendResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateScoringTrendResponse" }, - "reasonCode": { + "requestId": { "type": "string" }, - "expectedVersion": { - "type": "integer", - "format": "int64" + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "RecommendationProfileResponse": { + "ApiResponseCompanyCandidateScoringResponse": { "type": "object", "properties": { - "jobId": { + "code": { "type": "integer", - "format": "int64" - }, - "industryCode": { - "type": "string" + "format": "int32" }, - "jobFamilyCode": { + "message": { "type": "string" }, - "riasecWeights": { - "type": "object", - "additionalProperties": { - "type": "integer" - } + "data": { + "$ref": "#/components/schemas/CompanyCandidateScoringResponse" }, - "abilityExpectations": { - "type": "object", - "additionalProperties": { - "type": "integer" - } + "requestId": { + "type": "string" }, - "enabled": { - "type": "boolean" + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCompanyPublicView": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" }, - "reviewStatus": { + "message": { "type": "string" }, - "reviewReason": { - "type": "string" + "data": { + "$ref": "#/components/schemas/CompanyPublicView" }, - "version": { - "type": "integer", - "format": "int64" + "requestId": { + "type": "string" }, - "updatedAt": { + "timestamp": { "type": "string", "format": "date-time" } } }, - "RecommendationFeedbackResponse": { + "ApiResponseDashboardOverviewResponse": { "type": "object", "properties": { - "jobId": { + "code": { "type": "integer", - "format": "int64" + "format": "int32" }, - "feedbackType": { + "message": { "type": "string" }, - "reasonType": { - "type": "string" + "data": { + "$ref": "#/components/schemas/DashboardOverviewResponse" }, - "status": { + "requestId": { "type": "string" }, - "version": { - "type": "integer", - "format": "int64" + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "JobRecommendationItem": { + "ApiResponseDimensionScoreDetailResponse": { "type": "object", "properties": { - "jobId": { + "code": { "type": "integer", - "format": "int64" + "format": "int32" }, - "title": { + "message": { "type": "string" }, - "city": { - "type": "string" + "data": { + "$ref": "#/components/schemas/DimensionScoreDetailResponse" }, - "employmentType": { + "requestId": { "type": "string" }, - "salaryMin": { - "type": "integer" - }, - "salaryMax": { - "type": "integer" - }, - "mode": { + "timestamp": { "type": "string", - "enum": [ - "PERSONALIZED", - "FALLBACK" - ] - }, - "score": { - "type": "number" - }, - "reasons": { - "type": "array", - "items": { - "type": "string" - } + "format": "date-time" } } }, - "ApiResponseListTaxonomyResponse": { + "ApiResponseJobRankingItem": { "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "int32" }, "message": { "type": "string" }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TaxonomyResponse" - } + "$ref": "#/components/schemas/JobRankingItem" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "ApiResponseRecommendationFeedbackResponse": { + "ApiResponseJobRankingListResponse": { "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "int32" }, "message": { "type": "string" }, "data": { - "$ref": "#/components/schemas/RecommendationFeedbackResponse" + "$ref": "#/components/schemas/JobRankingListResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "ApiResponseRecommendationProfileResponse": { + "ApiResponseListAppealHistoryItem": { "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "int32" }, "message": { "type": "string" }, "data": { - "$ref": "#/components/schemas/RecommendationProfileResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/AppealHistoryItem" + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "ApiResponsePageJobRecommendationItem": { + "ApiResponseListMapStringObject": { "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "int32" }, "message": { "type": "string" }, "data": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/JobRecommendationItem" - } - }, - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - }, - "size": { - "type": "integer" - }, - "totalPages": { - "type": "integer" - } + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "ApiResponsePageRecommendationProfileResponse": { + "ApiResponseListSnapshotHistoryItem": { "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "int32" }, "message": { "type": "string" }, "data": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RecommendationProfileResponse" - } - }, - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - }, - "size": { - "type": "integer" - }, - "totalPages": { - "type": "integer" - } + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotHistoryItem" } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "CompanyReviewJobResponse": { + "ApiResponseListWeightTemplateResponse": { "type": "object", "properties": { - "id": { + "code": { "type": "integer", - "format": "int64" + "format": "int32" }, - "title": { + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WeightTemplateResponse" + } + }, + "requestId": { "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "CompanyReviewReplyResponse": { + "ApiResponseMapStringObject": { "type": "object", "properties": { - "id": { + "code": { "type": "integer", - "format": "int64" + "format": "int32" }, - "content": { + "message": { "type": "string" }, - "status": { - "type": "string" + "data": { + "type": "object", + "additionalProperties": {} }, - "version": { - "type": "integer", - "format": "int64" + "requestId": { + "type": "string" }, - "updatedAt": { + "timestamp": { "type": "string", "format": "date-time" - }, - "rejectionReason": { - "type": "string", - "nullable": true } } }, - "CompanyReviewItemResponse": { + "ApiResponseMapStringString": { "type": "object", "properties": { - "reviewId": { + "code": { "type": "integer", - "format": "int64" - }, - "job": { - "$ref": "#/components/schemas/CompanyReviewJobResponse" - }, - "rating": { - "type": "integer" + "format": "int32" }, - "content": { + "message": { "type": "string" }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "dimensionScores": { + "data": { "type": "object", "additionalProperties": { - "type": "integer" + "type": "string" } }, - "reviewStatus": { + "requestId": { "type": "string" }, - "reviewCreatedAt": { + "timestamp": { "type": "string", "format": "date-time" - }, - "reply": { - "$ref": "#/components/schemas/CompanyReviewReplyResponse" } } }, - "CompanyReviewDetailResponse": { - "allOf": [ - { - "$ref": "#/components/schemas/CompanyReviewItemResponse" - } - ] - }, - "ReviewReplyPageResponse": { + "ApiResponsePersonalizedScoreResponse": { "type": "object", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CompanyReviewItemResponse" - } - }, - "total": { + "code": { "type": "integer", - "format": "int64" + "format": "int32" }, - "page": { - "type": "integer" + "message": { + "type": "string" }, - "size": { - "type": "integer" + "data": { + "$ref": "#/components/schemas/PersonalizedScoreResponse" }, - "totalPages": { - "type": "integer" + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "CreateCompanyReviewReplyRequest": { + "ApiResponsePrivacySettingsResponse": { "type": "object", - "required": [ - "content", - "idempotencyKey" - ], "properties": { - "content": { + "code": { + "type": "integer", + "format": "int32", + "example": 200 + }, + "message": { "type": "string", - "minLength": 1, - "maxLength": 500 + "example": "ok" }, - "idempotencyKey": { + "data": { + "$ref": "#/components/schemas/PrivacySettingsResponse" + }, + "requestId": { "type": "string", - "format": "uuid", - "maxLength": 64 + "example": "req-1" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "UpdateCompanyReviewReplyRequest": { + "ApiResponseReviewWeightResponse": { "type": "object", - "required": [ - "content", - "expectedVersion" - ], "properties": { - "content": { - "type": "string", - "minLength": 1, - "maxLength": 500 + "code": { + "type": "integer", + "format": "int32" }, - "expectedVersion": { + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/ReviewWeightResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseScoreTrendResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/ScoreTrendResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "ReviewReplyDecisionRequest": { + "AppealDecisionRequest": { "type": "object", - "required": [ - "decision" - ], "properties": { "decision": { "type": "string", - "enum": [ - "APPROVE", - "REJECT" - ] + "minLength": 1 }, "reason": { - "type": "string", - "maxLength": 500 + "type": "string" } - } + }, + "required": [ + "decision" + ] }, - "AdminReviewReplyResponse": { + "AppealHistoryItem": { "type": "object", "properties": { - "replyId": { + "id": { "type": "integer", "format": "int64" }, - "reviewId": { - "type": "integer", - "format": "int64" + "fromStatus": { + "type": "string" }, - "jobId": { + "toStatus": { + "type": "string" + }, + "actorUserId": { "type": "integer", "format": "int64" }, - "jobTitle": { + "actorRole": { "type": "string" }, - "companyId": { + "reason": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "AppealResponse": { + "type": "object", + "properties": { + "appealId": { "type": "integer", "format": "int64" }, - "companyName": { + "candidateId": { + "type": "integer", + "format": "int64" + }, + "dimension": { "type": "string" }, - "rating": { - "type": "integer" + "period": { + "type": "string" }, - "reviewContent": { + "status": { "type": "string" }, - "reviewModerationStatus": { + "reason": { "type": "string" }, - "replyContent": { + "decision": { "type": "string" }, - "replyStatus": { + "decisionReason": { "type": "string" }, - "version": { + "reviewerId": { "type": "integer", "format": "int64" }, - "riskScore": { - "type": "number" + "reviewedAt": { + "type": "string", + "format": "date-time" }, - "riskLevel": { - "type": "string" + "rollbackAt": { + "type": "string", + "format": "date-time" }, - "reasonCodes": { - "type": "array", - "items": { - "type": "string" - } + "scoreAtAppeal": { + "type": "number" + }, + "snapshotVersionAtAppeal": { + "type": "integer", + "format": "int64" + }, + "rulesVersion": { + "type": "string" }, "createdAt": { "type": "string", "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" } } }, - "AdminReviewReplyPageResponse": { + "ApplicationCreateRequest": { "type": "object", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AdminReviewReplyResponse" - } - }, - "total": { + "jobId": { "type": "integer", "format": "int64" - }, - "page": { - "type": "integer" - }, - "size": { - "type": "integer" - }, - "totalPages": { - "type": "integer" } - } + }, + "required": [ + "jobId" + ] }, - "ApiResponseReviewReplyPageResponse": { + "ApplicationStatusRequest": { "type": "object", "properties": { - "code": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/ReviewReplyPageResponse" - }, - "requestId": { - "type": "string" - }, - "timestamp": { + "status": { "type": "string", - "format": "date-time" + "minLength": 1, + "pattern": "^(VIEWED|INTERVIEW|REJECTED|HIRED)$" } - } + }, + "required": [ + "status" + ] }, - "ApiResponseCompanyReviewDetailResponse": { + "CandidateDetailView": { "type": "object", "properties": { - "code": { - "type": "integer" - }, - "message": { - "type": "string" + "applicationId": { + "type": "integer", + "format": "int64" }, - "data": { - "$ref": "#/components/schemas/CompanyReviewDetailResponse" + "jobId": { + "type": "integer", + "format": "int64" }, - "requestId": { + "jobTitle": { "type": "string" }, - "timestamp": { - "type": "string", - "format": "date-time" - } - } - }, - "ApiResponseCompanyReviewReplyResponse": { - "type": "object", - "properties": { - "code": { - "type": "integer" + "jobCity": { + "type": "string" }, - "message": { + "jobEmploymentType": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/CompanyReviewReplyResponse" + "status": { + "type": "string" }, - "requestId": { + "coverLetter": { "type": "string" }, - "timestamp": { + "createdAt": { "type": "string", "format": "date-time" - } - } - }, - "ApiResponseAdminReviewReplyPageResponse": { - "type": "object", - "properties": { - "code": { - "type": "integer" }, - "message": { - "type": "string" + "updatedAt": { + "type": "string", + "format": "date-time" }, - "data": { - "$ref": "#/components/schemas/AdminReviewReplyPageResponse" + "candidateName": { + "type": "string" }, - "requestId": { + "candidateAvatarUrl": { "type": "string" }, - "timestamp": { - "type": "string", - "format": "date-time" - } - } - }, - "ApiResponseAdminReviewReplyResponse": { - "type": "object", - "properties": { - "code": { - "type": "integer" + "candidateBio": { + "type": "string" }, - "message": { + "candidateLocation": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/AdminReviewReplyResponse" + "candidateSchool": { + "type": "string" }, - "requestId": { + "candidateEducation": { "type": "string" }, - "timestamp": { - "type": "string", - "format": "date-time" + "candidateGraduationYear": { + "type": "integer", + "format": "int32" + }, + "candidateSkills": { + "type": "array", + "items": { + "type": "string" + } } } }, - "AccountDeletionResponse": { + "CandidateFeedbackRequest": { "type": "object", "properties": { - "userId": { + "rating": { "type": "integer", - "format": "int64", - "example": 2 - }, - "status": { - "type": "string", - "enum": [ - "NONE", - "PENDING", - "COMPLETED" - ], - "example": "PENDING", - "description": "注销状态:NONE/PENDING/COMPLETED" - }, - "requestedAt": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "effectiveAt": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "cancelledAt": { - "type": "string", - "format": "date-time", - "nullable": true + "format": "int32", + "maximum": 5, + "minimum": 1 }, - "completedAt": { + "content": { "type": "string", - "format": "date-time", - "nullable": true + "maxLength": 1000, + "minLength": 0 }, - "version": { - "type": "integer", - "format": "int64", - "example": 0 + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 50, + "minLength": 1 + }, + "maxItems": 10, + "minItems": 0 } - } + }, + "required": [ + "content", + "rating" + ] }, - "AdminDashboardOverviewResponse": { + "CandidateFeedbackResponse": { "type": "object", + "description": "脱敏后的候选人反馈", "properties": { - "totalCompanies": { + "id": { "type": "integer", - "format": "int32" + "format": "int64", + "description": "反馈 ID", + "example": 1 }, - "totalJobs": { + "applicationId": { "type": "integer", - "format": "int32" + "format": "int64", + "description": "投递 ID", + "example": 31 }, - "totalCandidates": { + "rating": { "type": "integer", - "format": "int32" + "format": "int32", + "description": "评分,范围 1-5", + "example": 5 }, - "avgCandidateScore": { - "type": "number" + "content": { + "type": "string", + "description": "反馈内容", + "example": "沟通良好" }, - "topCompanies": { + "tags": { "type": "array", + "description": "反馈标签", "items": { - "$ref": "#/components/schemas/CompanyScoreItem" + "type": "string" } - } - } - }, - "ApiResponse": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": {}, - "requestId": { - "type": "string" }, - "timestamp": { + "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "创建时间", + "example": "2026-08-25T10:00:00" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "更新时间", + "example": "2026-08-25T10:00:00" } } }, - "CareerProgressResponse": { + "CandidateFeedbackSummary": { "type": "object", "properties": { - "todo": { - "$ref": "#/components/schemas/ProgressTodoSummary" + "totalCount": { + "type": "integer", + "format": "int32" }, - "applications": { - "$ref": "#/components/schemas/PaginatedApplications" + "averageRating": { + "type": "number" }, - "generatedAt": { - "type": "string", - "format": "date-time" + "ratingDistribution": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagCount" + } } } }, - "ProgressTodoSummary": { + "CandidateItemView": { "type": "object", "properties": { - "pendingInterviewResponseCount": { + "applicationId": { "type": "integer", "format": "int64" }, - "upcomingConfirmedInterviewCount": { + "jobId": { "type": "integer", "format": "int64" }, - "pendingOfferResponseCount": { - "type": "integer", - "format": "int64" + "jobTitle": { + "type": "string" }, - "unreadNotificationCount": { - "type": "integer", - "format": "int64" + "status": { + "type": "string" }, - "unreadReportDecisionCount": { + "candidateName": { + "type": "string" + }, + "candidateSchool": { + "type": "string" + }, + "candidateEducation": { + "type": "string" + }, + "candidateGraduationYear": { "type": "integer", - "format": "int64" + "format": "int32" + }, + "candidateAvatarUrl": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" } } }, - "PaginatedApplications": { + "CandidateListResponse": { "type": "object", "properties": { "items": { "type": "array", "items": { - "$ref": "#/components/schemas/CareerProgressItem" + "$ref": "#/components/schemas/CandidateItemView" } }, "total": { @@ -10365,3050 +13649,3167 @@ } } }, - "CareerProgressItem": { + "CandidateMatchSignals": { "type": "object", "properties": { - "applicationId": { - "type": "integer", - "format": "int64" - }, - "applicationStatus": { - "type": "string" - }, - "appliedAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "job": { - "$ref": "#/components/schemas/JobSummary" - }, - "nextInterview": { - "$ref": "#/components/schemas/InterviewSummary" - }, - "latestSubmittedInterviewScore": { - "$ref": "#/components/schemas/ScoreSummary" - }, - "offer": { - "$ref": "#/components/schemas/OfferSummary" + "candidateInterestScore": { + "$ref": "#/components/schemas/MatchSignal" }, - "competition": { - "$ref": "#/components/schemas/CompetitionSummary" + "hrSuitabilityScore": { + "$ref": "#/components/schemas/MatchSignal" } } }, - "JobSummary": { + "CandidatePercentileResponse": { "type": "object", "properties": { - "jobId": { + "candidateId": { "type": "integer", "format": "int64" }, - "title": { + "status": { "type": "string" }, - "companyName": { + "scoreType": { "type": "string" }, - "city": { - "type": "string" + "score": { + "type": "number" }, - "employmentType": { - "type": "string" + "percentile": { + "type": "number" }, - "jobStatus": { - "type": "string" - } - } - }, - "InterviewSummary": { - "type": "object", - "properties": { - "interviewId": { + "cohortSize": { "type": "integer", - "format": "int64" + "format": "int32" }, - "roundNo": { + "sampleCount": { "type": "integer", "format": "int32" }, - "title": { - "type": "string" - }, - "interviewType": { - "type": "string" - }, - "startAt": { - "type": "string", - "format": "date-time" - }, - "endAt": { - "type": "string", - "format": "date-time" - }, - "status": { - "type": "string" + "minimumSampleCount": { + "type": "integer", + "format": "int32" } } }, - "ScoreSummary": { + "CandidateRankingDetailResponse": { "type": "object", "properties": { - "interviewId": { + "applicationId": { "type": "integer", "format": "int64" }, - "overallScore": { - "type": "number" - }, - "recommendation": { - "type": "string" - }, - "submittedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "OfferSummary": { - "type": "object", - "properties": { - "offerId": { + "jobId": { "type": "integer", "format": "int64" }, - "status": { + "candidateName": { "type": "string" }, - "title": { + "candidateSchool": { "type": "string" }, - "expireAt": { - "type": "string", - "format": "date-time" - }, - "onboardingDate": { - "type": "string", - "format": "date" - } - } - }, - "CompetitionSummary": { - "type": "object", - "properties": { - "eligible": { - "type": "boolean" - }, - "rank": { - "type": "integer", - "format": "int32" - }, - "cohortSize": { - "type": "integer", - "format": "int32" - }, "overallScore": { "type": "number" }, "percentile": { "type": "number" }, - "status": { - "type": "string" - }, - "mySnapshotPeriod": { - "type": "string" + "cohortSize": { + "type": "integer", + "format": "int32" }, - "rulesVersion": { - "type": "string" + "dimensionScores": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DimensionScoreItem" + } } } }, - "ApiResponseCareerProgressResponse": { + "CandidateRankingItem": { "type": "object", "properties": { - "code": { + "applicationId": { "type": "integer", - "format": "int32", - "example": 200 + "format": "int64" }, - "message": { - "type": "string", - "example": "ok" + "jobId": { + "type": "integer", + "format": "int64" }, - "data": { - "$ref": "#/components/schemas/CareerProgressResponse" + "candidateName": { + "type": "string" }, - "requestId": { - "type": "string", - "example": "req-1" + "candidateSchool": { + "type": "string" + }, + "overallScore": { + "type": "number" + }, + "percentile": { + "type": "number" }, - "timestamp": { - "type": "string", - "format": "date-time" + "sampleCount": { + "type": "integer", + "format": "int32" } } }, - "ApiResponseAccountDeletionResponse": { + "CandidateRankingListResponse": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32", - "example": 200 + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CandidateRankingItem" + } }, - "message": { - "type": "string", - "example": "ok" + "total": { + "type": "integer", + "format": "int64" }, - "data": { - "$ref": "#/components/schemas/AccountDeletionResponse" + "page": { + "type": "integer", + "format": "int32" }, - "requestId": { - "type": "string", - "example": "req-1" + "size": { + "type": "integer", + "format": "int32" }, - "timestamp": { - "type": "string", - "format": "date-time" + "totalPages": { + "type": "integer", + "format": "int32" } } }, - "ApiResponseAdminDashboardOverviewResponse": { + "CandidateScoreCardResponse": { "type": "object", "properties": { - "code": { + "candidateId": { "type": "integer", - "format": "int32" + "format": "int64" }, - "message": { + "status": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/AdminDashboardOverviewResponse" + "overallScore": { + "type": "number" }, - "requestId": { - "type": "string" + "sampleCount": { + "type": "integer", + "format": "int32" }, - "timestamp": { + "feedbackSummary": { + "$ref": "#/components/schemas/CandidateFeedbackSummary" + }, + "matchSignals": { + "$ref": "#/components/schemas/CandidateMatchSignals" + }, + "unsupportedDimensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "generatedAt": { "type": "string", "format": "date-time" } } }, - "ApiResponseAppealResponse": { + "CandidateScoreTrendItem": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { + "month": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/AppealResponse" + "feedbackCount": { + "type": "integer", + "format": "int32" }, - "requestId": { - "type": "string" + "averageRating": { + "type": "number" }, - "timestamp": { - "type": "string", - "format": "date-time" + "ratingScore": { + "type": "number" } } }, - "ApiResponseCandidateDetailView": { + "CandidateScoreTrendResponse": { "type": "object", "properties": { - "code": { + "candidateId": { "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/CandidateDetailView" + "format": "int64" }, - "requestId": { - "type": "string" + "months": { + "type": "integer", + "format": "int32" }, - "timestamp": { - "type": "string", - "format": "date-time" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CandidateScoreTrendItem" + } } } }, - "ApiResponseCandidateFeedbackResponse": { + "CandidateScoringOverviewResponse": { "type": "object", "properties": { - "code": { + "candidateId": { "type": "integer", - "format": "int32" + "format": "int64" }, - "message": { + "status": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/CandidateFeedbackResponse" + "overallScore": { + "type": "number" }, - "requestId": { + "weights": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "dimensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DimensionItem" + } + }, + "rulesVersion": { "type": "string" }, - "timestamp": { + "generatedAt": { "type": "string", "format": "date-time" + }, + "explanationVersion": { + "type": "string", + "description": "解释契约版本,与评分规则版本独立" + }, + "coverage": { + "$ref": "#/components/schemas/CandidateScoringCoverage" + }, + "comparabilityNotice": { + "type": "string" } } }, - "ApiResponseCandidateListResponse": { + "CandidateScoringTrendResponse": { "type": "object", "properties": { - "code": { + "candidateId": { "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/CandidateListResponse" + "format": "int64" }, - "requestId": { - "type": "string" + "months": { + "type": "integer", + "format": "int32" }, - "timestamp": { - "type": "string", - "format": "date-time" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TrendItem" + } } } }, - "ApiResponseCandidatePercentileResponse": { + "CompanyCandidateScoringResponse": { "type": "object", "properties": { - "code": { + "applicationId": { "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" + "format": "int64" }, - "data": { - "$ref": "#/components/schemas/CandidatePercentileResponse" + "overallScore": { + "type": "number" }, - "requestId": { + "status": { "type": "string" }, - "timestamp": { + "dimensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanyDimensionItem" + } + }, + "generatedAt": { "type": "string", "format": "date-time" + }, + "rulesVersion": { + "type": "string" } } }, - "ApiResponseCandidateRankingDetailResponse": { + "CompanyCreateRequest": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" + "name": { + "type": "string", + "maxLength": 100, + "minLength": 0 }, - "message": { + "logoUrl": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/CandidateRankingDetailResponse" + "description": { + "type": "string" }, - "requestId": { + "industry": { "type": "string" }, - "timestamp": { - "type": "string", - "format": "date-time" + "website": { + "type": "string" + }, + "address": { + "type": "string" } - } + }, + "required": [ + "name" + ] }, - "ApiResponseCandidateRankingListResponse": { + "CompanyDimensionItem": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { + "dimension": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/CandidateRankingListResponse" + "score": { + "type": "number" }, - "requestId": { + "status": { "type": "string" - }, - "timestamp": { - "type": "string", - "format": "date-time" } } }, - "ApiResponseCandidateScoreCardResponse": { + "CompanyMatchRuleRequest": { "type": "object", "properties": { - "code": { + "requiredCandidateScore": { "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/CandidateScoreCardResponse" - }, - "requestId": { - "type": "string" - }, - "timestamp": { - "type": "string", - "format": "date-time" + "format": "int32", + "maximum": 100, + "minimum": 0 } - } + }, + "required": [ + "requiredCandidateScore" + ] }, - "ApiResponseCandidateScoreTrendResponse": { + "CompanyPublicView": { "type": "object", "properties": { - "code": { + "id": { "type": "integer", - "format": "int32" + "format": "int64" }, - "message": { + "name": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/CandidateScoreTrendResponse" + "logoUrl": { + "type": "string" }, - "requestId": { + "description": { "type": "string" }, - "timestamp": { - "type": "string", - "format": "date-time" + "industry": { + "type": "string" + }, + "verified": { + "type": "boolean" } } }, - "ApiResponseCandidateScoringOverviewResponse": { + "CompanyScoreItem": { "type": "object", "properties": { - "code": { + "companyId": { "type": "integer", - "format": "int32" + "format": "int64" }, - "message": { + "companyName": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/CandidateScoringOverviewResponse" - }, - "requestId": { - "type": "string" + "avgCandidateScore": { + "type": "number" }, - "timestamp": { - "type": "string", - "format": "date-time" + "jobCount": { + "type": "integer", + "format": "int32" } } }, - "ApiResponseCandidateScoringTrendResponse": { + "CompanyUpdateRequest": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" + "name": { + "type": "string", + "maxLength": 120, + "minLength": 0 }, - "message": { + "logoUrl": { + "type": "string", + "maxLength": 500, + "minLength": 0 + }, + "description": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/CandidateScoringTrendResponse" + "industry": { + "type": "string", + "maxLength": 80, + "minLength": 0 }, - "requestId": { - "type": "string" + "website": { + "type": "string", + "maxLength": 255, + "minLength": 0 }, - "timestamp": { + "address": { "type": "string", - "format": "date-time" + "maxLength": 255, + "minLength": 0 } - } + }, + "required": [ + "name" + ] }, - "ApiResponseCompanyCandidateScoringResponse": { + "CounterOfferCreateRequest": { "type": "object", "properties": { - "code": { + "proposedSalaryMin": { "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" + "format": "int32", + "minimum": 0 }, - "data": { - "$ref": "#/components/schemas/CompanyCandidateScoringResponse" + "proposedSalaryMax": { + "type": "integer", + "format": "int32", + "minimum": 0 }, - "requestId": { - "type": "string" + "proposedOnboardingDate": { + "type": "string", + "format": "date" }, - "timestamp": { + "reason": { "type": "string", - "format": "date-time" + "maxLength": 2000, + "minLength": 0 } } }, - "ApiResponseCompanyPublicView": { + "CreateAppealRequest": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" + "dimension": { + "type": "string", + "minLength": 1 }, - "data": { - "$ref": "#/components/schemas/CompanyPublicView" + "period": { + "type": "string", + "minLength": 1 }, - "requestId": { + "reason": { "type": "string" }, - "timestamp": { + "idempotencyKey": { "type": "string", - "format": "date-time" + "maxLength": 64, + "minLength": 0 } - } + }, + "required": [ + "dimension", + "period" + ] }, - "ApiResponseDashboardOverviewResponse": { + "CreateConversationRequest": { "type": "object", "properties": { - "code": { + "participantUserId": { "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/DashboardOverviewResponse" - }, - "requestId": { - "type": "string" - }, - "timestamp": { - "type": "string", - "format": "date-time" + "format": "int64" } - } + }, + "required": [ + "participantUserId" + ] }, - "ApiResponseDimensionScoreDetailResponse": { + "DashboardOverviewResponse": { "type": "object", "properties": { - "code": { + "companyId": { + "type": "integer", + "format": "int64" + }, + "totalJobs": { "type": "integer", "format": "int32" }, - "message": { - "type": "string" + "publishedJobs": { + "type": "integer", + "format": "int32" }, - "data": { - "$ref": "#/components/schemas/DimensionScoreDetailResponse" + "avgJobScore": { + "type": "number" }, - "requestId": { - "type": "string" + "totalCandidates": { + "type": "integer", + "format": "int32" }, - "timestamp": { + "avgCandidateScore": { + "type": "number" + }, + "scoreDistribution": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + }, + "updatedAt": { "type": "string", "format": "date-time" } } }, - "ApiResponseJobRankingItem": { + "DataSourceItem": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/JobRankingItem" - }, - "requestId": { + "source": { "type": "string" }, - "timestamp": { + "time": { "type": "string", "format": "date-time" + }, + "note": { + "type": "string" } } }, - "ApiResponseJobRankingListResponse": { + "DimensionItem": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { + "dimension": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/JobRankingListResponse" + "score": { + "type": "number" }, - "requestId": { + "status": { "type": "string" }, - "timestamp": { - "type": "string", - "format": "date-time" + "weight": { + "type": "number" + }, + "summary": { + "type": "string" } } }, - "ApiResponseListAppealHistoryItem": { + "DimensionScoreDetailResponse": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" + "dimension": { + "type": "string" }, - "message": { + "score": { + "type": "number" + }, + "status": { "type": "string" }, - "data": { + "subScores": { "type": "array", "items": { - "$ref": "#/components/schemas/AppealHistoryItem" + "$ref": "#/components/schemas/SubScore" } }, - "requestId": { - "type": "string" + "reasons": { + "type": "array", + "items": { + "type": "string" + } }, - "timestamp": { + "dataSources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DataSourceItem" + } + }, + "updatedAt": { "type": "string", "format": "date-time" - } - } - }, - "ApiResponseListMapStringObject": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" }, - "message": { + "rulesVersion": { "type": "string" }, - "data": { + "displayName": { + "type": "string" + }, + "whatItMeasures": { + "type": "string" + }, + "interpretation": { + "type": "string" + }, + "limitations": { "type": "array", "items": { - "type": "object", - "additionalProperties": {} + "type": "string" } }, - "requestId": { - "type": "string" - }, - "timestamp": { + "evidenceLevel": { "type": "string", - "format": "date-time" - } - } - }, - "ApiResponseListSnapshotHistoryItem": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" + "enum": [ + "DIRECT_RULE", + "SELF_REPORTED", + "BEHAVIOR_PROXY", + "UNVALIDATED_PROXY" + ] }, - "message": { - "type": "string" + "ruleContributions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CandidateScoringRuleContribution" + } }, - "data": { + "improvementActions": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotHistoryItem" + "type": "string" } }, - "requestId": { + "explanationVersion": { "type": "string" }, - "timestamp": { + "validity": { + "$ref": "#/components/schemas/CandidateScoringValidity" + }, + "calculatedAt": { "type": "string", "format": "date-time" } } }, - "ApiResponseListWeightTemplateResponse": { + "DimensionScoreItem": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { + "dimension": { "type": "string" }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WeightTemplateResponse" - } + "score": { + "type": "number" }, - "requestId": { + "status": { "type": "string" - }, - "timestamp": { - "type": "string", - "format": "date-time" } } }, - "ApiResponseMapStringObject": { + "InterviewCancelRequest": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "additionalProperties": {} - }, - "requestId": { + "reason": { "type": "string" - }, - "timestamp": { - "type": "string", - "format": "date-time" } } }, - "ApiResponseMapStringString": { + "InterviewCreateRequest": { "type": "object", "properties": { - "code": { + "roundNo": { "type": "integer", "format": "int32" }, - "message": { - "type": "string" + "title": { + "type": "string", + "maxLength": 200, + "minLength": 0 }, - "data": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "interviewType": { + "type": "string", + "minLength": 1 }, - "requestId": { - "type": "string" + "startAt": { + "type": "string", + "format": "date-time" }, - "timestamp": { + "endAt": { "type": "string", "format": "date-time" - } - } - }, - "ApiResponsePersonalizedScoreResponse": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" }, - "message": { + "timezone": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/PersonalizedScoreResponse" + "meetingUrl": { + "type": "string" }, - "requestId": { + "address": { "type": "string" }, - "timestamp": { + "interviewerName": { "type": "string", - "format": "date-time" + "maxLength": 100, + "minLength": 0 + }, + "notes": { + "type": "string" } - } + }, + "required": [ + "endAt", + "interviewType", + "startAt", + "title" + ] }, - "ApiResponsePrivacySettingsResponse": { + "InterviewRespondRequest": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32", - "example": 200 - }, - "message": { - "type": "string", - "example": "ok" - }, - "data": { - "$ref": "#/components/schemas/PrivacySettingsResponse" - }, - "requestId": { + "action": { "type": "string", - "example": "req-1" + "minLength": 1 }, - "timestamp": { - "type": "string", - "format": "date-time" + "reason": { + "type": "string" } - } + }, + "required": [ + "action" + ] }, - "ApiResponseReviewWeightResponse": { + "InterviewUpdateRequest": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { + "title": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/ReviewWeightResponse" - }, - "requestId": { - "type": "string" + "startAt": { + "type": "string", + "format": "date-time" }, - "timestamp": { + "endAt": { "type": "string", "format": "date-time" - } - } - }, - "ApiResponseScoreTrendResponse": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" }, - "message": { + "timezone": { + "type": "string" + }, + "meetingUrl": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/ScoreTrendResponse" + "address": { + "type": "string" }, - "requestId": { + "interviewerName": { "type": "string" }, - "timestamp": { - "type": "string", - "format": "date-time" + "notes": { + "type": "string" + }, + "timeRangeValid": { + "type": "boolean" } } }, - "AppealDecisionRequest": { + "JobCreateRequest": { "type": "object", "properties": { - "decision": { + "title": { + "type": "string", + "maxLength": 120, + "minLength": 0 + }, + "description": { "type": "string", "minLength": 1 }, - "reason": { + "requirements": { "type": "string" + }, + "city": { + "type": "string", + "maxLength": 80, + "minLength": 0 + }, + "employmentType": { + "type": "string", + "maxLength": 30, + "minLength": 0 + }, + "salaryMin": { + "type": "integer", + "format": "int32" + }, + "salaryMax": { + "type": "integer", + "format": "int32" } }, "required": [ - "decision" + "city", + "description", + "employmentType", + "title" ] }, - "AppealHistoryItem": { + "JobRankingItem": { "type": "object", "properties": { - "id": { + "jobId": { "type": "integer", "format": "int64" }, - "fromStatus": { + "title": { "type": "string" }, - "toStatus": { + "city": { "type": "string" }, - "actorUserId": { + "employmentType": { + "type": "string" + }, + "avgRating": { + "type": "number" + }, + "reviewCount": { "type": "integer", - "format": "int64" + "format": "int32" }, - "actorRole": { - "type": "string" + "rank": { + "type": "integer", + "format": "int32" }, - "reason": { - "type": "string" + "cohortSize": { + "type": "integer", + "format": "int32" }, - "createdAt": { - "type": "string", - "format": "date-time" + "dimensionAverages": { + "type": "object", + "additionalProperties": { + "type": "number" + } } } }, - "AppealResponse": { + "JobRankingListResponse": { "type": "object", "properties": { - "appealId": { - "type": "integer", - "format": "int64" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JobRankingItem" + } }, - "candidateId": { + "total": { "type": "integer", "format": "int64" }, - "dimension": { - "type": "string" - }, - "period": { - "type": "string" + "page": { + "type": "integer", + "format": "int32" }, - "status": { - "type": "string" + "size": { + "type": "integer", + "format": "int32" }, - "reason": { - "type": "string" + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "JobReviewCreateRequest": { + "type": "object", + "properties": { + "rating": { + "type": "integer", + "format": "int32", + "maximum": 5, + "minimum": 1 }, - "decision": { - "type": "string" + "content": { + "type": "string", + "maxLength": 1000, + "minLength": 0 }, - "decisionReason": { - "type": "string" + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 50, + "minLength": 1 + }, + "maxItems": 10, + "minItems": 0 }, - "reviewerId": { + "dimensionScores": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + } + }, + "required": [ + "content", + "rating" + ] + }, + "JobReviewUpdateRequest": { + "type": "object", + "properties": { + "rating": { "type": "integer", - "format": "int64" + "format": "int32", + "maximum": 5, + "minimum": 1 }, - "reviewedAt": { + "content": { "type": "string", - "format": "date-time" + "maxLength": 1000, + "minLength": 1 }, - "rollbackAt": { + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 50, + "minLength": 1 + }, + "maxItems": 10, + "minItems": 0 + }, + "status": { "type": "string", - "format": "date-time" + "pattern": "^(PUBLISHED|HIDDEN)$" }, - "scoreAtAppeal": { - "type": "number" + "dimensionScores": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + } + } + }, + "JobStatusRequest": { + "type": "object", + "properties": { + "status": { + "type": "string", + "minLength": 1, + "pattern": "^(PUBLISHED|CLOSED)$" + } + }, + "required": [ + "status" + ] + }, + "JobUpdateRequest": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 120, + "minLength": 0 }, - "snapshotVersionAtAppeal": { - "type": "integer", - "format": "int64" + "description": { + "type": "string" }, - "rulesVersion": { + "requirements": { "type": "string" }, - "createdAt": { + "city": { "type": "string", - "format": "date-time" + "maxLength": 80, + "minLength": 0 }, - "updatedAt": { + "employmentType": { "type": "string", - "format": "date-time" + "maxLength": 30, + "minLength": 0 + }, + "salaryMin": { + "type": "integer", + "format": "int32" + }, + "salaryMax": { + "type": "integer", + "format": "int32" } } }, - "ApplicationCreateRequest": { + "LoginRequest": { "type": "object", "properties": { - "jobId": { - "type": "integer", - "format": "int64" + "phone": { + "type": "string", + "minLength": 1, + "pattern": "[0-9]{11}" + }, + "code": { + "type": "string", + "minLength": 1, + "pattern": "[0-9]{6}" + }, + "purpose": { + "type": "string" } }, "required": [ - "jobId" + "code", + "phone" ] }, - "ApplicationStatusRequest": { + "MatchSignal": { "type": "object", "properties": { - "status": { - "type": "string", - "minLength": 1, - "pattern": "^(VIEWED|INTERVIEW|REJECTED|HIRED)$" + "average": { + "type": "number" + }, + "sampleCount": { + "type": "integer", + "format": "int32" } - }, - "required": [ - "status" - ] + } }, - "CandidateDetailView": { + "OfferCreateRequest": { "type": "object", "properties": { "applicationId": { "type": "integer", "format": "int64" }, - "jobId": { - "type": "integer", - "format": "int64" - }, - "jobTitle": { - "type": "string" + "title": { + "type": "string", + "maxLength": 120, + "minLength": 0 }, - "jobCity": { - "type": "string" + "salaryMin": { + "type": "integer", + "format": "int32", + "minimum": 0 }, - "jobEmploymentType": { - "type": "string" + "salaryMax": { + "type": "integer", + "format": "int32", + "minimum": 0 }, - "status": { - "type": "string" + "salaryCurrency": { + "type": "string", + "maxLength": 10, + "minLength": 0 }, - "coverLetter": { - "type": "string" + "salaryPeriod": { + "type": "string", + "pattern": "^(MONTH|YEAR|HOUR)$" }, - "createdAt": { + "workCity": { "type": "string", - "format": "date-time" + "maxLength": 80, + "minLength": 0 }, - "updatedAt": { + "employmentType": { "type": "string", - "format": "date-time" + "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$" }, - "candidateName": { - "type": "string" + "probationMonths": { + "type": "integer", + "format": "int32", + "minimum": 0 }, - "candidateAvatarUrl": { - "type": "string" + "benefits": { + "type": "string", + "maxLength": 2000, + "minLength": 0 }, - "candidateBio": { - "type": "string" + "description": { + "type": "string", + "maxLength": 5000, + "minLength": 0 }, - "candidateLocation": { - "type": "string" + "expireAt": { + "type": "string", + "format": "date-time" }, - "candidateSchool": { - "type": "string" + "onboardingDate": { + "type": "string", + "format": "date" }, - "candidateEducation": { - "type": "string" + "baseSalary": { + "type": "integer", + "format": "int32", + "minimum": 0 }, - "candidateGraduationYear": { + "housingAllowance": { "type": "integer", - "format": "int32" + "format": "int32", + "minimum": 0 }, - "candidateSkills": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "CandidateFeedbackRequest": { - "type": "object", - "properties": { - "rating": { + "annualBonusMonths": { "type": "integer", "format": "int32", - "maximum": 5, - "minimum": 1 + "minimum": 0 }, - "content": { + "otherAllowances": { "type": "string", "maxLength": 1000, "minLength": 0 + } + }, + "required": [ + "applicationId", + "description", + "expireAt", + "title", + "workCity" + ] + }, + "OfferStatusRequest": { + "type": "object", + "properties": { + "status": { + "type": "string", + "minLength": 1, + "pattern": "^(SENT|NEGOTIATING|ACCEPTED|REJECTED|EXPIRED|WITHDRAWN)$" }, - "tags": { - "type": "array", - "items": { - "type": "string", - "maxLength": 50, - "minLength": 1 - }, - "maxItems": 10, - "minItems": 0 + "reason": { + "type": "string", + "maxLength": 255, + "minLength": 0 } }, "required": [ - "content", - "rating" + "status" ] }, - "CandidateFeedbackResponse": { + "OfferUpdateRequest": { "type": "object", - "description": "脱敏后的候选人反馈", "properties": { - "id": { + "title": { + "type": "string", + "maxLength": 120, + "minLength": 0 + }, + "salaryMin": { "type": "integer", - "format": "int64", - "description": "反馈 ID", - "example": 1 + "format": "int32", + "minimum": 0 }, - "applicationId": { + "salaryMax": { "type": "integer", - "format": "int64", - "description": "投递 ID", - "example": 31 + "format": "int32", + "minimum": 0 }, - "rating": { + "salaryCurrency": { + "type": "string", + "maxLength": 10, + "minLength": 0 + }, + "salaryPeriod": { + "type": "string", + "pattern": "^(MONTH|YEAR|HOUR)$" + }, + "workCity": { + "type": "string", + "maxLength": 80, + "minLength": 0 + }, + "employmentType": { + "type": "string", + "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$" + }, + "probationMonths": { "type": "integer", "format": "int32", - "description": "评分,范围 1-5", - "example": 5 + "minimum": 0 }, - "content": { + "benefits": { "type": "string", - "description": "反馈内容", - "example": "沟通良好" + "maxLength": 2000, + "minLength": 0 }, - "tags": { - "type": "array", - "description": "反馈标签", - "items": { - "type": "string" - } + "description": { + "type": "string", + "maxLength": 5000, + "minLength": 0 }, - "createdAt": { + "expireAt": { "type": "string", - "format": "date-time", - "description": "创建时间", - "example": "2026-08-25T10:00:00" + "format": "date-time" }, - "updatedAt": { + "onboardingDate": { "type": "string", - "format": "date-time", - "description": "更新时间", - "example": "2026-08-25T10:00:00" + "format": "date" + }, + "baseSalary": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "housingAllowance": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "annualBonusMonths": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "otherAllowances": { + "type": "string", + "maxLength": 1000, + "minLength": 0 } } }, - "CandidateFeedbackSummary": { + "PersonalizedScoreResponse": { "type": "object", "properties": { - "totalCount": { + "jobId": { "type": "integer", - "format": "int32" + "format": "int64" }, - "averageRating": { + "score": { "type": "number" }, - "ratingDistribution": { + "status": { + "type": "string" + }, + "source": { + "type": "string" + }, + "templateCode": { + "type": "string" + }, + "configuredWeights": { "type": "object", "additionalProperties": { - "type": "integer", - "format": "int32" + "type": "number" } }, - "tags": { + "effectiveWeights": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "dimensionScores": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "coveredWeight": { + "type": "number" + }, + "missingDimensions": { "type": "array", "items": { - "$ref": "#/components/schemas/TagCount" + "type": "string" } } } }, - "CandidateItemView": { + "PrivacySettingsResponse": { "type": "object", "properties": { - "applicationId": { + "id": { "type": "integer", - "format": "int64" + "format": "int64", + "example": 1, + "nullable": true }, - "jobId": { + "studentUserId": { "type": "integer", - "format": "int64" - }, - "jobTitle": { - "type": "string" - }, - "status": { - "type": "string" - }, - "candidateName": { - "type": "string" + "format": "int64", + "example": 2 }, - "candidateSchool": { - "type": "string" + "resumeVisibility": { + "$ref": "#/components/schemas/ResumeVisibility", + "description": "简历可见性(PRIVATE/APPLIED_COMPANIES/VERIFIED_COMPANIES)", + "example": "APPLIED_COMPANIES" }, - "candidateEducation": { - "type": "string" + "contactVisible": { + "type": "boolean", + "example": true }, - "candidateGraduationYear": { + "version": { "type": "integer", - "format": "int32" - }, - "candidateAvatarUrl": { - "type": "string" + "format": "int64", + "example": 0 }, "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "nullable": true }, "updatedAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "nullable": true } } }, - "CandidateListResponse": { + "PrivacySettingsUpdateRequest": { "type": "object", + "required": [ + "resumeVisibility", + "contactVisible" + ], "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CandidateItemView" - } - }, - "total": { - "type": "integer", - "format": "int64" - }, - "page": { - "type": "integer", - "format": "int32" + "resumeVisibility": { + "$ref": "#/components/schemas/ResumeVisibility", + "description": "简历可见性(必填)", + "example": "APPLIED_COMPANIES" }, - "size": { - "type": "integer", - "format": "int32" + "contactVisible": { + "type": "boolean", + "description": "联系方式是否对企业可见(必填)", + "example": true }, - "totalPages": { + "version": { "type": "integer", - "format": "int32" + "format": "int64", + "description": "乐观锁版本号(可选)", + "example": 0 } } }, - "CandidateMatchSignals": { + "ResumeVisibility": { + "type": "string", + "enum": [ + "PRIVATE", + "APPLIED_COMPANIES", + "VERIFIED_COMPANIES" + ], + "description": "简历可见性:PRIVATE 私有;APPLIED_COMPANIES 仅对企业有效投递可见;VERIFIED_COMPANIES 仅对认证企业可见", + "example": "PRIVATE" + }, + "ReviewDecisionRequest": { "type": "object", "properties": { - "candidateInterestScore": { - "$ref": "#/components/schemas/MatchSignal" + "decision": { + "type": "string", + "minLength": 1, + "pattern": "^(APPROVED|REJECTED)$" }, - "hrSuitabilityScore": { - "$ref": "#/components/schemas/MatchSignal" + "reason": { + "type": "string", + "maxLength": 500, + "minLength": 0 } - } + }, + "required": [ + "decision" + ] }, - "CandidatePercentileResponse": { + "ReviewWeightResponse": { "type": "object", "properties": { - "candidateId": { - "type": "integer", - "format": "int64" - }, - "status": { + "source": { "type": "string" }, - "scoreType": { + "templateCode": { "type": "string" }, - "score": { - "type": "number" - }, - "percentile": { - "type": "number" - }, - "cohortSize": { - "type": "integer", - "format": "int32" - }, - "sampleCount": { - "type": "integer", - "format": "int32" + "configuredWeights": { + "type": "object", + "additionalProperties": { + "type": "number" + } }, - "minimumSampleCount": { - "type": "integer", - "format": "int32" + "updatedAt": { + "type": "string", + "format": "date-time" } } }, - "CandidateRankingDetailResponse": { + "SaveWeightRequest": { "type": "object", "properties": { - "applicationId": { - "type": "integer", - "format": "int64" - }, - "jobId": { - "type": "integer", - "format": "int64" - }, - "candidateName": { - "type": "string" - }, - "candidateSchool": { + "templateCode": { "type": "string" }, - "overallScore": { - "type": "number" - }, - "percentile": { - "type": "number" - }, - "cohortSize": { - "type": "integer", - "format": "int32" - }, - "dimensionScores": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DimensionScoreItem" + "weights": { + "type": "object", + "additionalProperties": { + "type": "number" } } } }, - "CandidateRankingItem": { + "ScoreRequest": { "type": "object", "properties": { - "applicationId": { - "type": "integer", - "format": "int64" - }, - "jobId": { - "type": "integer", - "format": "int64" - }, - "candidateName": { - "type": "string" - }, - "candidateSchool": { - "type": "string" - }, - "overallScore": { - "type": "number" - }, - "percentile": { - "type": "number" - }, - "sampleCount": { + "score": { "type": "integer", - "format": "int32" + "format": "int32", + "maximum": 100, + "minimum": 0 } - } + }, + "required": [ + "score" + ] }, - "CandidateRankingListResponse": { + "ScoreTrendResponse": { "type": "object", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CandidateRankingItem" - } - }, - "total": { + "companyId": { "type": "integer", "format": "int64" }, - "page": { + "months": { "type": "integer", "format": "int32" }, - "size": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TrendItem" + } + } + } + }, + "SendMessageRequest": { + "type": "object", + "properties": { + "conversationId": { "type": "integer", - "format": "int32" + "format": "int64" }, - "totalPages": { - "type": "integer", - "format": "int32" + "content": { + "type": "string", + "maxLength": 2000, + "minLength": 0 } - } + }, + "required": [ + "content", + "conversationId" + ] }, - "CandidateScoreCardResponse": { + "SnapshotHistoryItem": { "type": "object", "properties": { - "candidateId": { + "id": { "type": "integer", "format": "int64" }, - "status": { + "appealId": { + "type": "integer", + "format": "int64" + }, + "action": { "type": "string" }, - "overallScore": { + "dimension": { + "type": "string" + }, + "period": { + "type": "string" + }, + "beforeScore": { "type": "number" }, - "sampleCount": { + "afterScore": { + "type": "number" + }, + "beforeVersion": { "type": "integer", - "format": "int32" + "format": "int64" }, - "feedbackSummary": { - "$ref": "#/components/schemas/CandidateFeedbackSummary" + "afterVersion": { + "type": "integer", + "format": "int64" }, - "matchSignals": { - "$ref": "#/components/schemas/CandidateMatchSignals" + "beforeRulesVersion": { + "type": "string" }, - "unsupportedDimensions": { - "type": "array", - "items": { - "type": "string" - } + "afterRulesVersion": { + "type": "string" }, - "generatedAt": { + "createdAt": { "type": "string", "format": "date-time" } } }, - "CandidateScoreTrendItem": { + "StudentMatchRuleRequest": { "type": "object", "properties": { - "month": { - "type": "string" - }, - "feedbackCount": { + "requiredHrScore": { "type": "integer", - "format": "int32" - }, - "averageRating": { - "type": "number" - }, - "ratingScore": { - "type": "number" + "format": "int32", + "maximum": 100, + "minimum": 0 } - } + }, + "required": [ + "requiredHrScore" + ] }, - "CandidateScoreTrendResponse": { + "StudentPreferenceRequest": { "type": "object", "properties": { - "candidateId": { + "expectedSalaryMin": { "type": "integer", - "format": "int64" + "format": "int32", + "minimum": 0 }, - "months": { + "expectedSalaryMax": { "type": "integer", - "format": "int32" + "format": "int32", + "minimum": 0 }, - "items": { + "preferredCities": { "type": "array", "items": { - "$ref": "#/components/schemas/CandidateScoreTrendItem" - } - } - } - }, - "CandidateScoringOverviewResponse": { - "type": "object", - "properties": { - "candidateId": { - "type": "integer", - "format": "int64" - }, - "status": { - "type": "string" - }, - "overallScore": { - "type": "number" - }, - "weights": { - "type": "object", - "additionalProperties": { - "type": "number" - } + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "maxItems": 20, + "minItems": 0 }, - "dimensions": { + "employmentTypes": { "type": "array", "items": { - "$ref": "#/components/schemas/DimensionItem" - } - }, - "rulesVersion": { - "type": "string" + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "maxItems": 20, + "minItems": 0 }, - "generatedAt": { - "type": "string", - "format": "date-time" + "preferredIndustries": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "maxItems": 20, + "minItems": 0 }, - "explanationVersion": { - "type": "string", - "description": "解释契约版本,与评分规则版本独立" + "preferredJobTypes": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "maxItems": 20, + "minItems": 0 }, - "coverage": { - "$ref": "#/components/schemas/CandidateScoringCoverage" + "remoteAllowed": { + "type": "boolean" }, - "comparabilityNotice": { - "type": "string" + "salaryRangeValid": { + "type": "boolean" } } }, - "CandidateScoringTrendResponse": { + "StudentProfileUpdateRequest": { "type": "object", "properties": { - "candidateId": { - "type": "integer", - "format": "int64" + "realName": { + "type": "string", + "maxLength": 50, + "minLength": 0 }, - "months": { + "bio": { + "type": "string" + }, + "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" }, - "items": { + "resumeUrl": { + "type": "string", + "maxLength": 500, + "minLength": 0 + }, + "skills": { "type": "array", "items": { - "$ref": "#/components/schemas/TrendItem" + "type": "string" } } } }, - "CompanyCandidateScoringResponse": { + "SubScore": { "type": "object", "properties": { - "applicationId": { - "type": "integer", - "format": "int64" - }, - "overallScore": { - "type": "number" - }, - "status": { + "name": { "type": "string" }, - "dimensions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CompanyDimensionItem" - } + "score": { + "type": "number" }, - "generatedAt": { - "type": "string", - "format": "date-time" + "weight": { + "type": "number" }, - "rulesVersion": { + "description": { + "type": "string" + } + } + }, + "TagCount": { + "type": "object", + "properties": { + "name": { "type": "string" + }, + "count": { + "type": "integer", + "format": "int32" } } }, - "CompanyCreateRequest": { + "TrendItem": { "type": "object", "properties": { - "name": { - "type": "string", - "maxLength": 100, - "minLength": 0 - }, - "logoUrl": { - "type": "string" - }, - "description": { + "period": { "type": "string" }, - "industry": { - "type": "string" + "overallScore": { + "type": "number" }, - "website": { + "rulesVersion": { "type": "string" }, - "address": { - "type": "string" + "dimensions": { + "type": "object", + "additionalProperties": { + "type": "number" + } } - }, - "required": [ - "name" - ] + } }, - "CompanyDimensionItem": { + "VerificationDecisionRequest": { "type": "object", "properties": { - "dimension": { - "type": "string" - }, - "score": { - "type": "number" + "decision": { + "type": "string", + "minLength": 1 }, - "status": { + "reason": { "type": "string" } - } + }, + "required": [ + "decision" + ] }, - "CompanyMatchRuleRequest": { + "VerificationSubmitRequest": { "type": "object", "properties": { - "requiredCandidateScore": { - "type": "integer", - "format": "int32", - "maximum": 100, - "minimum": 0 + "materialsJson": { + "type": "string", + "minLength": 1 } }, "required": [ - "requiredCandidateScore" + "materialsJson" ] }, - "CompanyPublicView": { + "WeightTemplateResponse": { "type": "object", "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "name": { + "code": { "type": "string" }, - "logoUrl": { + "name": { "type": "string" }, "description": { "type": "string" }, - "industry": { - "type": "string" - }, - "verified": { - "type": "boolean" + "weights": { + "type": "object", + "additionalProperties": { + "type": "number" + } } } }, - "CompanyScoreItem": { + "InterviewScoreSaveRequest": { "type": "object", "properties": { - "companyId": { + "technicalScore": { "type": "integer", - "format": "int64" + "minimum": 1, + "maximum": 5, + "nullable": true }, - "companyName": { - "type": "string" + "communicationScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true }, - "avgCandidateScore": { - "type": "number" + "problemSolvingScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true }, - "jobCount": { + "roleFitScore": { "type": "integer", - "format": "int32" + "minimum": 1, + "maximum": 5, + "nullable": true + }, + "professionalismScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true + }, + "recommendation": { + "type": "string", + "enum": [ + "STRONG_HIRE", + "HIRE", + "NEUTRAL", + "NO_HIRE" + ], + "nullable": true, + "description": "企业手动选择,不根据 overallScore 自动推导" + }, + "comment": { + "type": "string", + "nullable": true } - } + }, + "description": "评分草稿保存请求;overallScore 由服务端计算,客户端不得传入" }, - "CompanyUpdateRequest": { + "InterviewScoreResponse": { "type": "object", "properties": { - "name": { + "id": { + "type": "integer", + "format": "int64" + }, + "interviewId": { + "type": "integer", + "format": "int64" + }, + "applicationId": { + "type": "integer", + "format": "int64" + }, + "status": { "type": "string", - "maxLength": 120, - "minLength": 0 + "enum": [ + "DRAFT", + "SUBMITTED" + ] }, - "logoUrl": { + "technicalScore": { + "type": "integer", + "nullable": true + }, + "communicationScore": { + "type": "integer", + "nullable": true + }, + "problemSolvingScore": { + "type": "integer", + "nullable": true + }, + "roleFitScore": { + "type": "integer", + "nullable": true + }, + "professionalismScore": { + "type": "integer", + "nullable": true + }, + "overallScore": { + "type": "number", + "format": "decimal", + "nullable": true, + "description": "由服务端 BigDecimal 计算:(t+c+ps+rf+p)/5,保留两位小数 HALF_UP" + }, + "recommendation": { "type": "string", - "maxLength": 500, - "minLength": 0 + "enum": [ + "STRONG_HIRE", + "HIRE", + "NEUTRAL", + "NO_HIRE" + ], + "nullable": true }, - "description": { - "type": "string" + "comment": { + "type": "string", + "nullable": true }, - "industry": { + "submittedAt": { "type": "string", - "maxLength": 80, - "minLength": 0 + "format": "date-time", + "nullable": true }, - "website": { + "version": { + "type": "integer", + "format": "int64" + }, + "createdAt": { "type": "string", - "maxLength": 255, - "minLength": 0 + "format": "date-time" }, - "address": { + "updatedAt": { "type": "string", - "maxLength": 255, - "minLength": 0 + "format": "date-time" } }, - "required": [ - "name" - ] + "description": "面试评分响应(脱敏:不返回 companyUserId/studentUserId/evaluatorUserId/手机号/邮箱)" }, - "CounterOfferCreateRequest": { + "ApiResponseInterviewScoreResponse": { "type": "object", "properties": { - "proposedSalaryMin": { + "code": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int32" }, - "proposedSalaryMax": { + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/InterviewScoreResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "InterviewScoreHistoryItem": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "scoreId": { + "type": "integer", + "format": "int64" + }, + "interviewId": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int64" }, - "proposedOnboardingDate": { + "fromStatus": { "type": "string", - "format": "date" + "enum": [ + "DRAFT", + "SUBMITTED" + ], + "nullable": true }, - "reason": { - "type": "string", - "maxLength": 2000, - "minLength": 0 - } - } - }, - "CreateAppealRequest": { - "type": "object", - "properties": { - "dimension": { + "toStatus": { "type": "string", - "minLength": 1 + "enum": [ + "DRAFT", + "SUBMITTED" + ] }, - "period": { + "actorRole": { "type": "string", - "minLength": 1 + "example": "COMPANY" }, - "reason": { - "type": "string" + "snapshot": { + "type": "object", + "additionalProperties": {} }, - "idempotencyKey": { + "createdAt": { "type": "string", - "maxLength": 64, - "minLength": 0 + "format": "date-time" } }, - "required": [ - "dimension", - "period" - ] + "description": "评分历史条目(脱敏:不返回 actorUserId/手机号/邮箱)" }, - "CreateConversationRequest": { + "ApiResponseListInterviewScoreHistoryItem": { "type": "object", "properties": { - "participantUserId": { + "code": { "type": "integer", - "format": "int64" + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InterviewScoreHistoryItem" + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } - }, - "required": [ - "participantUserId" - ] + } }, - "DashboardOverviewResponse": { + "CreateReportRequest": { "type": "object", "properties": { - "companyId": { - "type": "integer", - "format": "int64" + "targetType": { + "type": "string", + "enum": [ + "USER", + "MESSAGE" + ], + "description": "举报目标类型;USER 必填 targetUserId,MESSAGE 必填 messageId" }, - "totalJobs": { + "targetUserId": { "type": "integer", - "format": "int32" + "format": "int64", + "description": "targetType=USER 时必填" }, - "publishedJobs": { + "messageId": { "type": "integer", - "format": "int32" - }, - "avgJobScore": { - "type": "number" + "format": "int64", + "description": "targetType=MESSAGE 时必填" }, - "totalCandidates": { - "type": "integer", - "format": "int32" + "category": { + "type": "string", + "maxLength": 40, + "description": "举报分类,如 HARASSMENT" }, - "avgCandidateScore": { - "type": "number" + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 1000 }, - "scoreDistribution": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "int32" - } + "evidenceUrls": { + "type": "array", + "items": { + "type": "string", + "maxLength": 500 + }, + "description": "最多 5 个,仅 http/https,最多 500 字符" }, - "updatedAt": { + "idempotencyKey": { "type": "string", - "format": "date-time" + "maxLength": 64, + "description": "幂等键(可选)" } } }, - "DataSourceItem": { + "ReportDecisionRequest": { "type": "object", "properties": { - "source": { - "type": "string" - }, - "time": { + "decision": { "type": "string", - "format": "date-time" + "enum": [ + "RESOLVED", + "REJECTED" + ] }, "note": { - "type": "string" + "type": "string", + "maxLength": 1000, + "description": "REJECTED 必填" } } }, - "DimensionItem": { + "ReportResponse": { "type": "object", "properties": { - "dimension": { - "type": "string" - }, - "score": { - "type": "number" + "id": { + "type": "integer", + "format": "int64" }, - "status": { - "type": "string" + "targetType": { + "type": "string", + "enum": [ + "USER", + "MESSAGE" + ] }, - "weight": { - "type": "number" + "targetUserId": { + "type": "integer", + "format": "int64" }, - "summary": { - "type": "string" - } - } - }, - "DimensionScoreDetailResponse": { - "type": "object", - "properties": { - "dimension": { - "type": "string" + "messageId": { + "type": "integer", + "format": "int64" }, - "score": { - "type": "number" + "reportedUserId": { + "type": "integer", + "format": "int64" }, - "status": { + "category": { "type": "string" }, - "subScores": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SubScore" - } + "reason": { + "type": "string" }, - "reasons": { + "evidenceUrls": { "type": "array", "items": { "type": "string" } }, - "dataSources": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DataSourceItem" - } - }, - "updatedAt": { + "status": { "type": "string", - "format": "date-time" + "enum": [ + "PENDING", + "RESOLVED", + "REJECTED" + ] }, - "rulesVersion": { - "type": "string" + "decision": { + "type": "string", + "enum": [ + "RESOLVED", + "REJECTED" + ] }, - "displayName": { + "decisionNote": { "type": "string" }, - "whatItMeasures": { - "type": "string" + "decidedAt": { + "type": "string", + "format": "date-time" }, - "interpretation": { - "type": "string" + "createdAt": { + "type": "string", + "format": "date-time" }, - "limitations": { - "type": "array", - "items": { - "type": "string" - } + "updatedAt": { + "type": "string", + "format": "date-time" }, - "evidenceLevel": { + "messagePreview": { "type": "string", - "enum": [ - "DIRECT_RULE", - "SELF_REPORTED", - "BEHAVIOR_PROXY", - "UNVALIDATED_PROXY" - ] + "description": "MESSAGE 举报的脱敏消息预览,不返回会话其他敏感内容" }, - "ruleContributions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CandidateScoringRuleContribution" - } + "reportedUser": { + "$ref": "#/components/schemas/ReportResponseReportedUser" }, - "improvementActions": { + "history": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/ReportResponseDecisionHistoryItem" } + } + } + }, + "ReportResponseReportedUser": { + "type": "object", + "properties": { + "userId": { + "type": "integer", + "format": "int64" }, - "explanationVersion": { + "nickname": { "type": "string" }, - "validity": { - "$ref": "#/components/schemas/CandidateScoringValidity" - }, - "calculatedAt": { - "type": "string", - "format": "date-time" + "role": { + "type": "string" } } }, - "DimensionScoreItem": { + "ReportResponseDecisionHistoryItem": { "type": "object", "properties": { - "dimension": { + "id": { + "type": "integer", + "format": "int64" + }, + "fromStatus": { "type": "string" }, - "score": { - "type": "number" + "toStatus": { + "type": "string" }, - "status": { + "actorUserId": { + "type": "integer", + "format": "int64" + }, + "actorRole": { + "type": "string" + }, + "note": { "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" } } }, - "InterviewCancelRequest": { + "BlockRelationResponse": { "type": "object", "properties": { - "reason": { + "id": { + "type": "integer", + "format": "int64" + }, + "blockedUserId": { + "type": "integer", + "format": "int64" + }, + "nickname": { "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" } } }, - "InterviewCreateRequest": { + "ApiResponseReportResponse": { "type": "object", "properties": { - "roundNo": { + "code": { "type": "integer", "format": "int32" }, - "title": { - "type": "string", - "maxLength": 200, - "minLength": 0 + "message": { + "type": "string" }, - "interviewType": { - "type": "string", - "minLength": 1 + "data": { + "$ref": "#/components/schemas/ReportResponse" }, - "startAt": { - "type": "string", - "format": "date-time" + "requestId": { + "type": "string" }, - "endAt": { + "timestamp": { "type": "string", "format": "date-time" + } + } + }, + "ApiResponsePageResponseReportResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" }, - "timezone": { + "message": { "type": "string" }, - "meetingUrl": { - "type": "string" + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReportResponse" + } + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + } + } }, - "address": { + "requestId": { "type": "string" }, - "interviewerName": { + "timestamp": { "type": "string", - "maxLength": 100, - "minLength": 0 - }, - "notes": { - "type": "string" + "format": "date-time" } - }, - "required": [ - "endAt", - "interviewType", - "startAt", - "title" - ] + } }, - "InterviewRespondRequest": { + "ApiResponseBlockRelationResponse": { "type": "object", "properties": { - "action": { - "type": "string", - "minLength": 1 + "code": { + "type": "integer", + "format": "int32" }, - "reason": { - "type": "string" - } - }, - "required": [ - "action" - ] - }, - "InterviewUpdateRequest": { - "type": "object", - "properties": { - "title": { + "message": { "type": "string" }, - "startAt": { - "type": "string", - "format": "date-time" + "data": { + "$ref": "#/components/schemas/BlockRelationResponse" }, - "endAt": { + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", "format": "date-time" + } + } + }, + "ApiResponsePageResponseBlockRelationResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" }, - "timezone": { + "message": { "type": "string" }, - "meetingUrl": { - "type": "string" + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BlockRelationResponse" + } + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + } + } }, - "address": { + "requestId": { "type": "string" }, - "interviewerName": { + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "CreateAbilityAttemptRequest": { + "type": "object", + "required": [ + "typeCode" + ], + "properties": { + "typeCode": { "type": "string" }, - "notes": { - "type": "string" + "idempotencyKey": { + "type": "string", + "maxLength": 64 + } + } + }, + "SaveAbilityAnswersRequest": { + "type": "object", + "required": [ + "answers" + ], + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AbilityAnswerItem" + } + } + } + }, + "AbilityAnswerItem": { + "type": "object", + "required": [ + "attemptQuestionId", + "selectedOptionId" + ], + "properties": { + "attemptQuestionId": { + "type": "integer", + "format": "int64" }, - "timeRangeValid": { - "type": "boolean" + "selectedOptionId": { + "type": "string", + "maxLength": 4 } } }, - "JobCreateRequest": { + "AbilityTypeResponse": { "type": "object", "properties": { - "title": { - "type": "string", - "maxLength": 120, - "minLength": 0 + "typeCode": { + "type": "string" }, - "description": { - "type": "string", - "minLength": 1 + "displayName": { + "type": "string" }, - "requirements": { + "description": { "type": "string" }, - "city": { - "type": "string", - "maxLength": 80, - "minLength": 0 + "questionCount": { + "type": "integer", + "format": "int32" }, - "employmentType": { - "type": "string", - "maxLength": 30, - "minLength": 0 + "attemptQuestionCount": { + "type": "integer", + "format": "int32" }, - "salaryMin": { + "timeLimitMinutes": { "type": "integer", "format": "int32" }, - "salaryMax": { + "resultValidDays": { "type": "integer", "format": "int32" + }, + "status": { + "type": "string" } - }, - "required": [ - "city", - "description", - "employmentType", - "title" - ] + } }, - "JobRankingItem": { + "AbilityAttemptResponse": { "type": "object", "properties": { - "jobId": { + "attemptId": { "type": "integer", "format": "int64" }, - "title": { - "type": "string" - }, - "city": { - "type": "string" - }, - "employmentType": { + "status": { "type": "string" }, - "avgRating": { - "type": "number" - }, - "reviewCount": { + "answeredCount": { "type": "integer", "format": "int32" }, - "rank": { + "totalQuestions": { "type": "integer", "format": "int32" }, - "cohortSize": { + "completionRate": { + "type": "number" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "remainingSeconds": { "type": "integer", - "format": "int32" + "format": "int64", + "nullable": true }, - "dimensionAverages": { - "type": "object", - "additionalProperties": { - "type": "number" + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AbilityAttemptQuestionView" } } } }, - "JobRankingListResponse": { + "AbilityAttemptQuestionView": { "type": "object", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/JobRankingItem" - } - }, - "total": { + "attemptQuestionId": { "type": "integer", "format": "int64" }, - "page": { + "position": { "type": "integer", "format": "int32" }, - "size": { - "type": "integer", - "format": "int32" + "itemType": { + "type": "string" }, - "totalPages": { - "type": "integer", - "format": "int32" + "stem": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AbilityOptionView" + } + }, + "selectedOptionId": { + "type": "string" } } }, - "JobReviewCreateRequest": { + "AbilityOptionView": { "type": "object", "properties": { - "rating": { + "optionId": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "AbilityResultResponse": { + "type": "object", + "properties": { + "attemptId": { "type": "integer", - "format": "int32", - "maximum": 5, - "minimum": 1 + "format": "int64" }, - "content": { + "typeCode": { + "type": "string" + }, + "status": { + "type": "string" + }, + "validityStatus": { + "type": "string" + }, + "validUntil": { "type": "string", - "maxLength": 1000, - "minLength": 0 + "format": "date-time", + "nullable": true }, - "tags": { + "dimensionScores": { "type": "array", "items": { - "type": "string", - "maxLength": 50, - "minLength": 1 - }, - "maxItems": 10, - "minItems": 0 + "$ref": "#/components/schemas/AbilityDimensionScore" + } }, - "dimensionScores": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "int32" + "overallScore": { + "type": "number" + }, + "weaknesses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AbilityWeakness" } - } - }, - "required": [ - "content", - "rating" - ] - }, - "JobReviewUpdateRequest": { - "type": "object", - "properties": { - "rating": { + }, + "improvementAreas": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AbilityImprovementArea" + } + }, + "radar": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AbilityRadarEntry" + } + }, + "answeredCount": { "type": "integer", - "format": "int32", - "maximum": 5, - "minimum": 1 + "format": "int32" }, - "content": { - "type": "string", - "maxLength": 1000, - "minLength": 1 + "completionRate": { + "type": "number" }, - "tags": { + "questionFeedback": { "type": "array", "items": { - "type": "string", - "maxLength": 50, - "minLength": 1 - }, - "maxItems": 10, - "minItems": 0 + "$ref": "#/components/schemas/AbilityQuestionFeedback" + } }, - "status": { + "questionSetVersion": { + "type": "string" + }, + "rulesVersion": { + "type": "string" + }, + "generatedAt": { "type": "string", - "pattern": "^(PUBLISHED|HIDDEN)$" + "format": "date-time" }, - "dimensionScores": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "int32" - } + "disclaimer": { + "type": "string" } } }, - "JobStatusRequest": { - "type": "object", - "properties": { - "status": { - "type": "string", - "minLength": 1, - "pattern": "^(PUBLISHED|CLOSED)$" - } - }, - "required": [ - "status" - ] - }, - "JobUpdateRequest": { + "AbilityDimensionScore": { "type": "object", "properties": { - "title": { - "type": "string", - "maxLength": 120, - "minLength": 0 - }, - "description": { - "type": "string" - }, - "requirements": { + "code": { "type": "string" }, - "city": { - "type": "string", - "maxLength": 80, - "minLength": 0 - }, - "employmentType": { - "type": "string", - "maxLength": 30, - "minLength": 0 + "score": { + "type": "number" }, - "salaryMin": { + "answeredCount": { "type": "integer", "format": "int32" + } + } + }, + "AbilityWeakness": { + "type": "object", + "properties": { + "code": { + "type": "string" }, - "salaryMax": { - "type": "integer", - "format": "int32" + "score": { + "type": "number" } } }, - "LoginRequest": { + "AbilityImprovementArea": { "type": "object", "properties": { - "phone": { - "type": "string", - "minLength": 1, - "pattern": "[0-9]{11}" + "dimension": { + "type": "string" }, - "code": { - "type": "string", - "minLength": 1, - "pattern": "[0-9]{6}" + "currentScore": { + "type": "number" }, - "purpose": { + "summary": { "type": "string" + }, + "actions": { + "type": "array", + "items": { + "type": "string" + } } - }, - "required": [ - "code", - "phone" - ] + } }, - "MatchSignal": { + "AbilityRadarEntry": { "type": "object", "properties": { - "average": { - "type": "number" + "code": { + "type": "string" }, - "sampleCount": { - "type": "integer", - "format": "int32" + "value": { + "type": "number" } } }, - "OfferCreateRequest": { + "AbilityQuestionFeedback": { "type": "object", "properties": { - "applicationId": { + "attemptQuestionId": { "type": "integer", "format": "int64" }, - "title": { - "type": "string", - "maxLength": 120, - "minLength": 0 - }, - "salaryMin": { - "type": "integer", - "format": "int32", - "minimum": 0 - }, - "salaryMax": { + "position": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int32" }, - "salaryCurrency": { - "type": "string", - "maxLength": 10, - "minLength": 0 + "stem": { + "type": "string" }, - "salaryPeriod": { - "type": "string", - "pattern": "^(MONTH|YEAR|HOUR)$" + "selectedOptionId": { + "type": "string" }, - "workCity": { - "type": "string", - "maxLength": 80, - "minLength": 0 + "awardedScore": { + "type": "integer", + "format": "int32" }, - "employmentType": { - "type": "string", - "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$" + "maxScore": { + "type": "integer", + "format": "int32" }, - "probationMonths": { + "explanation": { + "type": "string" + } + } + }, + "AbilityHistoryItem": { + "type": "object", + "properties": { + "attemptId": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int64" }, - "benefits": { - "type": "string", - "maxLength": 2000, - "minLength": 0 + "typeCode": { + "type": "string" }, - "description": { - "type": "string", - "maxLength": 5000, - "minLength": 0 + "status": { + "type": "string" }, - "expireAt": { - "type": "string", - "format": "date-time" + "validityStatus": { + "type": "string" }, - "onboardingDate": { - "type": "string", - "format": "date" + "overallScore": { + "type": "number" }, - "baseSalary": { + "answeredCount": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int32" }, - "housingAllowance": { - "type": "integer", - "format": "int32", - "minimum": 0 + "completionRate": { + "type": "number" }, - "annualBonusMonths": { - "type": "integer", - "format": "int32", - "minimum": 0 + "startedAt": { + "type": "string", + "format": "date-time", + "nullable": true }, - "otherAllowances": { + "expiresAt": { "type": "string", - "maxLength": 1000, - "minLength": 0 - } - }, - "required": [ - "applicationId", - "description", - "expireAt", - "title", - "workCity" - ] - }, - "OfferStatusRequest": { - "type": "object", - "properties": { - "status": { + "format": "date-time", + "nullable": true + }, + "completedAt": { "type": "string", - "minLength": 1, - "pattern": "^(SENT|NEGOTIATING|ACCEPTED|REJECTED|EXPIRED|WITHDRAWN)$" + "format": "date-time", + "nullable": true }, - "reason": { + "validUntil": { "type": "string", - "maxLength": 255, - "minLength": 0 + "format": "date-time", + "nullable": true } - }, - "required": [ - "status" - ] + } }, - "OfferUpdateRequest": { + "ApiResponseAbilityTypeResponse": { "type": "object", "properties": { - "title": { - "type": "string", - "maxLength": 120, - "minLength": 0 - }, - "salaryMin": { - "type": "integer", - "format": "int32", - "minimum": 0 - }, - "salaryMax": { + "code": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int32" }, - "salaryCurrency": { - "type": "string", - "maxLength": 10, - "minLength": 0 + "message": { + "type": "string" }, - "salaryPeriod": { - "type": "string", - "pattern": "^(MONTH|YEAR|HOUR)$" + "data": { + "$ref": "#/components/schemas/AbilityTypeResponse" }, - "workCity": { - "type": "string", - "maxLength": 80, - "minLength": 0 + "requestId": { + "type": "string" }, - "employmentType": { + "timestamp": { "type": "string", - "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$" - }, - "probationMonths": { + "format": "date-time" + } + } + }, + "ApiResponseAbilityAttemptResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int32" }, - "benefits": { - "type": "string", - "maxLength": 2000, - "minLength": 0 + "message": { + "type": "string" }, - "description": { - "type": "string", - "maxLength": 5000, - "minLength": 0 + "data": { + "$ref": "#/components/schemas/AbilityAttemptResponse" }, - "expireAt": { - "type": "string", - "format": "date-time" + "requestId": { + "type": "string" }, - "onboardingDate": { + "timestamp": { "type": "string", - "format": "date" - }, - "baseSalary": { + "format": "date-time" + } + } + }, + "ApiResponseAbilityResultResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int32" }, - "housingAllowance": { - "type": "integer", - "format": "int32", - "minimum": 0 + "message": { + "type": "string" }, - "annualBonusMonths": { - "type": "integer", - "format": "int32", - "minimum": 0 + "data": { + "$ref": "#/components/schemas/AbilityResultResponse" }, - "otherAllowances": { + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", - "maxLength": 1000, - "minLength": 0 + "format": "date-time" } } }, - "PersonalizedScoreResponse": { + "ApiResponsePageResponseAbilityHistoryItem": { "type": "object", "properties": { - "jobId": { + "code": { "type": "integer", - "format": "int64" + "format": "int32" }, - "score": { - "type": "number" + "message": { + "type": "string" }, - "status": { + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AbilityHistoryItem" + } + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + } + } + }, + "requestId": { "type": "string" }, - "source": { + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "RiskRuleResponse": { + "type": "object", + "properties": { + "ruleCode": { "type": "string" }, - "templateCode": { + "sourceAction": { "type": "string" }, - "configuredWeights": { - "type": "object", - "additionalProperties": { - "type": "number" - } + "thresholdCount": { + "type": "integer", + "format": "int32" }, - "effectiveWeights": { - "type": "object", - "additionalProperties": { - "type": "number" - } + "windowMinutes": { + "type": "integer", + "format": "int32" }, - "dimensionScores": { - "type": "object", - "additionalProperties": { - "type": "number" - } + "severity": { + "type": "string" }, - "coveredWeight": { - "type": "number" + "enabled": { + "type": "boolean" }, - "missingDimensions": { - "type": "array", - "items": { - "type": "string" - } + "version": { + "type": "integer", + "format": "int64" } } }, - "PrivacySettingsResponse": { + "RiskEventResponse": { "type": "object", "properties": { - "id": { + "eventId": { "type": "integer", - "format": "int64", - "example": 1, - "nullable": true + "format": "int64" }, - "studentUserId": { - "type": "integer", - "format": "int64", - "example": 2 + "ruleCode": { + "type": "string" }, - "resumeVisibility": { - "$ref": "#/components/schemas/ResumeVisibility", - "description": "简历可见性(PRIVATE/APPLIED_COMPANIES/VERIFIED_COMPANIES)", - "example": "APPLIED_COMPANIES" + "sourceAction": { + "type": "string" }, - "contactVisible": { - "type": "boolean", - "example": true + "severity": { + "type": "string" }, - "version": { + "status": { + "type": "string" + }, + "occurrenceCount": { "type": "integer", - "format": "int64", - "example": 0 + "format": "int32" }, - "createdAt": { + "windowStartedAt": { "type": "string", - "format": "date-time", - "nullable": true + "format": "date-time" }, - "updatedAt": { + "windowEndedAt": { "type": "string", - "format": "date-time", - "nullable": true + "format": "date-time" + }, + "detectedAt": { + "type": "string", + "format": "date-time" + }, + "version": { + "type": "integer", + "format": "int64" } } }, - "PrivacySettingsUpdateRequest": { + "RiskSeverityCount": { "type": "object", - "required": [ - "resumeVisibility", - "contactVisible" - ], "properties": { - "resumeVisibility": { - "$ref": "#/components/schemas/ResumeVisibility", - "description": "简历可见性(必填)", - "example": "APPLIED_COMPANIES" - }, - "contactVisible": { - "type": "boolean", - "description": "联系方式是否对企业可见(必填)", - "example": true + "severity": { + "type": "string" }, - "version": { + "count": { "type": "integer", - "format": "int64", - "description": "乐观锁版本号(可选)", - "example": 0 + "format": "int64" } } }, - "ResumeVisibility": { - "type": "string", - "enum": [ - "PRIVATE", - "APPLIED_COMPANIES", - "VERIFIED_COMPANIES" - ], - "description": "简历可见性:PRIVATE 私有;APPLIED_COMPANIES 仅对企业有效投递可见;VERIFIED_COMPANIES 仅对认证企业可见", - "example": "PRIVATE" + "RiskRuleCount": { + "type": "object", + "properties": { + "ruleCode": { + "type": "string" + }, + "count": { + "type": "integer", + "format": "int64" + } + } }, - "ReviewDecisionRequest": { + "RiskOverviewResponse": { "type": "object", "properties": { - "decision": { - "type": "string", - "minLength": 1, - "pattern": "^(APPROVED|REJECTED)$" + "openCount": { + "type": "integer", + "format": "int64" }, - "reason": { + "bySeverity": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RiskSeverityCount" + } + }, + "byRule": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RiskRuleCount" + } + }, + "lastDetectedAt": { "type": "string", - "maxLength": 500, - "minLength": 0 + "format": "date-time", + "nullable": true } - }, - "required": [ - "decision" - ] + } }, - "ReviewWeightResponse": { + "RiskTrendPoint": { "type": "object", "properties": { - "source": { - "type": "string" - }, - "templateCode": { - "type": "string" - }, - "configuredWeights": { - "type": "object", - "additionalProperties": { - "type": "number" - } - }, - "updatedAt": { + "time": { "type": "string", "format": "date-time" + }, + "count": { + "type": "integer", + "format": "int64" } } }, - "SaveWeightRequest": { + "RiskTrendResponse": { "type": "object", "properties": { - "templateCode": { + "bucket": { "type": "string" }, - "weights": { - "type": "object", - "additionalProperties": { - "type": "number" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RiskTrendPoint" } } } }, - "ScoreRequest": { + "RiskDistributionRow": { "type": "object", "properties": { - "score": { + "ruleCode": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "count": { "type": "integer", - "format": "int32", - "maximum": 100, - "minimum": 0 + "format": "int64" } - }, - "required": [ - "score" - ] + } }, - "ScoreTrendResponse": { + "RiskDistributionResponse": { "type": "object", "properties": { - "companyId": { - "type": "integer", - "format": "int64" - }, - "months": { - "type": "integer", - "format": "int32" - }, "items": { "type": "array", "items": { - "$ref": "#/components/schemas/TrendItem" + "$ref": "#/components/schemas/RiskDistributionRow" } } } }, - "SendMessageRequest": { + "RiskRuleUpdateRequest": { "type": "object", "properties": { - "conversationId": { + "enabled": { + "type": "boolean" + }, + "thresholdCount": { "type": "integer", - "format": "int64" + "format": "int32" }, - "content": { - "type": "string", - "maxLength": 2000, - "minLength": 0 + "windowMinutes": { + "type": "integer", + "format": "int32" + }, + "severity": { + "type": "string" + }, + "expectedVersion": { + "type": "integer", + "format": "int64" } }, "required": [ - "content", - "conversationId" + "enabled", + "thresholdCount", + "windowMinutes", + "severity", + "expectedVersion" ] }, - "SnapshotHistoryItem": { + "RiskEventActionRequest": { "type": "object", "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "appealId": { - "type": "integer", - "format": "int64" - }, "action": { "type": "string" }, - "dimension": { - "type": "string" - }, - "period": { + "reasonCode": { "type": "string" }, - "beforeScore": { - "type": "number" - }, - "afterScore": { - "type": "number" - }, - "beforeVersion": { - "type": "integer", - "format": "int64" - }, - "afterVersion": { + "expectedVersion": { "type": "integer", "format": "int64" - }, - "beforeRulesVersion": { - "type": "string" - }, - "afterRulesVersion": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - } - } - }, - "StudentMatchRuleRequest": { - "type": "object", - "properties": { - "requiredHrScore": { - "type": "integer", - "format": "int32", - "maximum": 100, - "minimum": 0 } }, "required": [ - "requiredHrScore" + "action", + "reasonCode", + "expectedVersion" ] }, - "StudentPreferenceRequest": { + "ApiResponseRiskOverviewResponse": { "type": "object", "properties": { - "expectedSalaryMin": { - "type": "integer", - "format": "int32", - "minimum": 0 - }, - "expectedSalaryMax": { + "code": { "type": "integer", - "format": "int32", - "minimum": 0 - }, - "preferredCities": { - "type": "array", - "items": { - "type": "string", - "maxLength": 100, - "minLength": 1 - }, - "maxItems": 20, - "minItems": 0 - }, - "employmentTypes": { - "type": "array", - "items": { - "type": "string", - "maxLength": 100, - "minLength": 1 - }, - "maxItems": 20, - "minItems": 0 - }, - "preferredIndustries": { - "type": "array", - "items": { - "type": "string", - "maxLength": 100, - "minLength": 1 - }, - "maxItems": 20, - "minItems": 0 - }, - "preferredJobTypes": { - "type": "array", - "items": { - "type": "string", - "maxLength": 100, - "minLength": 1 - }, - "maxItems": 20, - "minItems": 0 - }, - "remoteAllowed": { - "type": "boolean" - }, - "salaryRangeValid": { - "type": "boolean" - } - } - }, - "StudentProfileUpdateRequest": { - "type": "object", - "properties": { - "realName": { - "type": "string", - "maxLength": 50, - "minLength": 0 + "format": "int32" }, - "bio": { + "message": { "type": "string" }, - "location": { - "type": "string", - "maxLength": 100, - "minLength": 0 - }, - "education": { - "type": "string", - "maxLength": 100, - "minLength": 0 - }, - "school": { - "type": "string", - "maxLength": 150, - "minLength": 0 + "data": { + "$ref": "#/components/schemas/RiskOverviewResponse" }, - "graduationYear": { - "type": "integer", - "format": "int32" + "requestId": { + "type": "string" }, - "resumeUrl": { + "timestamp": { "type": "string", - "maxLength": 500, - "minLength": 0 - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } + "format": "date-time" } } }, - "SubScore": { + "ApiResponseRiskTrendResponse": { "type": "object", "properties": { - "name": { - "type": "string" + "code": { + "type": "integer", + "format": "int32" }, - "score": { - "type": "number" + "message": { + "type": "string" }, - "weight": { - "type": "number" + "data": { + "$ref": "#/components/schemas/RiskTrendResponse" }, - "description": { + "requestId": { "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "TagCount": { + "ApiResponseRiskDistributionResponse": { "type": "object", "properties": { - "name": { - "type": "string" - }, - "count": { + "code": { "type": "integer", "format": "int32" - } - } - }, - "TrendItem": { - "type": "object", - "properties": { - "period": { + }, + "message": { "type": "string" }, - "overallScore": { - "type": "number" + "data": { + "$ref": "#/components/schemas/RiskDistributionResponse" }, - "rulesVersion": { + "requestId": { "type": "string" }, - "dimensions": { - "type": "object", - "additionalProperties": { - "type": "number" - } + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "VerificationDecisionRequest": { + "ApiResponsePageResponseRiskEventResponse": { "type": "object", "properties": { - "decision": { - "type": "string", - "minLength": 1 + "code": { + "type": "integer", + "format": "int32" }, - "reason": { + "message": { "type": "string" - } - }, - "required": [ - "decision" - ] - }, - "VerificationSubmitRequest": { - "type": "object", - "properties": { - "materialsJson": { + }, + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RiskEventResponse" + } + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + } + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", - "minLength": 1 + "format": "date-time" } - }, - "required": [ - "materialsJson" - ] + } }, - "WeightTemplateResponse": { + "ApiResponseListRiskRuleResponse": { "type": "object", "properties": { "code": { - "type": "string" + "type": "integer", + "format": "int32" }, - "name": { + "message": { "type": "string" }, - "description": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RiskRuleResponse" + } + }, + "requestId": { "type": "string" }, - "weights": { - "type": "object", - "additionalProperties": { - "type": "number" - } + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "InterviewScoreSaveRequest": { + "ApiResponseRiskRuleResponse": { "type": "object", "properties": { - "technicalScore": { - "type": "integer", - "minimum": 1, - "maximum": 5, - "nullable": true - }, - "communicationScore": { - "type": "integer", - "minimum": 1, - "maximum": 5, - "nullable": true - }, - "problemSolvingScore": { + "code": { "type": "integer", - "minimum": 1, - "maximum": 5, - "nullable": true + "format": "int32" }, - "roleFitScore": { - "type": "integer", - "minimum": 1, - "maximum": 5, - "nullable": true + "message": { + "type": "string" }, - "professionalismScore": { - "type": "integer", - "minimum": 1, - "maximum": 5, - "nullable": true + "data": { + "$ref": "#/components/schemas/RiskRuleResponse" }, - "recommendation": { - "type": "string", - "enum": [ - "STRONG_HIRE", - "HIRE", - "NEUTRAL", - "NO_HIRE" - ], - "nullable": true, - "description": "企业手动选择,不根据 overallScore 自动推导" + "requestId": { + "type": "string" }, - "comment": { + "timestamp": { "type": "string", - "nullable": true + "format": "date-time" } - }, - "description": "评分草稿保存请求;overallScore 由服务端计算,客户端不得传入" + } }, - "InterviewScoreResponse": { + "ApiResponseRiskEventResponse": { "type": "object", "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "interviewId": { - "type": "integer", - "format": "int64" - }, - "applicationId": { - "type": "integer", - "format": "int64" - }, - "status": { - "type": "string", - "enum": [ - "DRAFT", - "SUBMITTED" - ] - }, - "technicalScore": { - "type": "integer", - "nullable": true - }, - "communicationScore": { - "type": "integer", - "nullable": true - }, - "problemSolvingScore": { - "type": "integer", - "nullable": true - }, - "roleFitScore": { - "type": "integer", - "nullable": true - }, - "professionalismScore": { + "code": { "type": "integer", - "nullable": true + "format": "int32" }, - "overallScore": { - "type": "number", - "format": "decimal", - "nullable": true, - "description": "由服务端 BigDecimal 计算:(t+c+ps+rf+p)/5,保留两位小数 HALF_UP" + "message": { + "type": "string" }, - "recommendation": { - "type": "string", - "enum": [ - "STRONG_HIRE", - "HIRE", - "NEUTRAL", - "NO_HIRE" - ], - "nullable": true + "data": { + "$ref": "#/components/schemas/RiskEventResponse" }, - "comment": { - "type": "string", - "nullable": true + "requestId": { + "type": "string" }, - "submittedAt": { + "timestamp": { "type": "string", - "format": "date-time", - "nullable": true + "format": "date-time" + } + } + }, + "TalentDiscoveryPreferenceResponse": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" }, "version": { "type": "integer", @@ -13422,77 +16823,134 @@ "type": "string", "format": "date-time" } - }, - "description": "面试评分响应(脱敏:不返回 companyUserId/studentUserId/evaluatorUserId/手机号/邮箱)" + } }, - "ApiResponseInterviewScoreResponse": { + "TalentDiscoveryPreferenceUpdateRequest": { "type": "object", + "required": [ + "enabled" + ], "properties": { - "code": { + "enabled": { + "type": "boolean" + }, + "expectedVersion": { "type": "integer", - "format": "int32" + "format": "int64", + "minimum": 0 + } + } + }, + "TalentDiscoveryCandidateResponse": { + "type": "object", + "properties": { + "candidateKey": { + "type": "string" }, - "message": { + "candidateLabel": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/InterviewScoreResponse" + "city": { + "type": "string" }, - "requestId": { + "education": { "type": "string" }, - "timestamp": { + "graduationYear": { + "type": "integer", + "format": "int32" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "fitBand": { "type": "string", - "format": "date-time" + "enum": [ + "HIGH", + "MEDIUM", + "BASIC" + ] + }, + "summary": { + "type": "string" + }, + "signalsAvailable": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "PREFERENCE", + "RIASEC", + "ABILITY" + ] + } } } }, - "InterviewScoreHistoryItem": { + "TalentDiscoveryPageResponse": { "type": "object", "properties": { - "id": { + "mode": { + "type": "string", + "enum": [ + "PERSONALIZED", + "UNAVAILABLE", + "NO_ELIGIBLE_CANDIDATE" + ] + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TalentDiscoveryCandidateResponse" + } + }, + "total": { "type": "integer", "format": "int64" }, - "scoreId": { + "page": { "type": "integer", - "format": "int64" + "format": "int32" }, - "interviewId": { + "size": { "type": "integer", - "format": "int64" + "format": "int32" }, - "fromStatus": { - "type": "string", - "enum": [ - "DRAFT", - "SUBMITTED" - ], - "nullable": true + "totalPages": { + "type": "integer", + "format": "int32" }, - "toStatus": { - "type": "string", - "enum": [ - "DRAFT", - "SUBMITTED" - ] + "message": { + "type": "string" + } + } + }, + "ApiResponseTalentDiscoveryPreferenceResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" }, - "actorRole": { - "type": "string", - "example": "COMPANY" + "message": { + "type": "string" }, - "snapshot": { - "type": "object", - "additionalProperties": {} + "data": { + "$ref": "#/components/schemas/TalentDiscoveryPreferenceResponse" }, - "createdAt": { + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", "format": "date-time" } - }, - "description": "评分历史条目(脱敏:不返回 actorUserId/手机号/邮箱)" + } }, - "ApiResponseListInterviewScoreHistoryItem": { + "ApiResponseTalentDiscoveryPageResponse": { "type": "object", "properties": { "code": { @@ -13503,10 +16961,7 @@ "type": "string" }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/InterviewScoreHistoryItem" - } + "$ref": "#/components/schemas/TalentDiscoveryPageResponse" }, "requestId": { "type": "string" @@ -13517,228 +16972,221 @@ } } }, - "CreateReportRequest": { + "CreateTalentInvitationRequest": { "type": "object", + "required": [ + "candidateKey", + "message", + "idempotencyKey" + ], "properties": { - "targetType": { - "type": "string", - "enum": [ - "USER", - "MESSAGE" - ], - "description": "举报目标类型;USER 必填 targetUserId,MESSAGE 必填 messageId" - }, - "targetUserId": { - "type": "integer", - "format": "int64", - "description": "targetType=USER 时必填" - }, - "messageId": { - "type": "integer", - "format": "int64", - "description": "targetType=MESSAGE 时必填" - }, - "category": { + "candidateKey": { "type": "string", - "maxLength": 40, - "description": "举报分类,如 HARASSMENT" + "maxLength": 128 }, - "reason": { + "message": { "type": "string", "minLength": 1, - "maxLength": 1000 - }, - "evidenceUrls": { - "type": "array", - "items": { - "type": "string", - "maxLength": 500 - }, - "description": "最多 5 个,仅 http/https,最多 500 字符" + "maxLength": 300 }, "idempotencyKey": { "type": "string", - "maxLength": 64, - "description": "幂等键(可选)" + "format": "uuid", + "maxLength": 64 } } }, - "ReportDecisionRequest": { + "TalentInvitationDecisionRequest": { "type": "object", + "required": [ + "decision", + "expectedVersion" + ], "properties": { "decision": { "type": "string", "enum": [ - "RESOLVED", - "REJECTED" + "ACCEPT", + "REJECT" ] }, - "note": { - "type": "string", - "maxLength": 1000, - "description": "REJECTED 必填" + "expectedVersion": { + "type": "integer", + "format": "int64", + "minimum": 0 } } }, - "ReportResponse": { + "CompanyTalentInvitationResponse": { "type": "object", "properties": { - "id": { + "invitationId": { "type": "integer", "format": "int64" }, - "targetType": { + "jobId": { + "type": "integer", + "format": "int64" + }, + "jobTitle": { + "type": "string" + }, + "candidateLabel": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { "type": "string", "enum": [ - "USER", - "MESSAGE" + "PENDING", + "ACCEPTED", + "REJECTED", + "EXPIRED" ] }, - "targetUserId": { + "createdAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "decidedAt": { + "type": "string", + "format": "date-time" + }, + "conversationId": { + "type": "integer", + "format": "int64" + } + } + }, + "StudentTalentInvitationResponse": { + "type": "object", + "properties": { + "invitationId": { "type": "integer", "format": "int64" }, - "messageId": { + "companyId": { "type": "integer", "format": "int64" }, - "reportedUserId": { + "companyName": { + "type": "string" + }, + "jobId": { "type": "integer", "format": "int64" }, - "category": { + "jobTitle": { "type": "string" }, - "reason": { + "jobCity": { "type": "string" }, - "evidenceUrls": { - "type": "array", - "items": { - "type": "string" - } + "message": { + "type": "string" }, "status": { "type": "string", "enum": [ "PENDING", - "RESOLVED", - "REJECTED" - ] - }, - "decision": { - "type": "string", - "enum": [ - "RESOLVED", - "REJECTED" + "ACCEPTED", + "REJECTED", + "EXPIRED" ] }, - "decisionNote": { - "type": "string" - }, - "decidedAt": { - "type": "string", - "format": "date-time" + "version": { + "type": "integer", + "format": "int64" }, "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { + "expiresAt": { "type": "string", "format": "date-time" }, - "messagePreview": { + "decidedAt": { "type": "string", - "description": "MESSAGE 举报的脱敏消息预览,不返回会话其他敏感内容" - }, - "reportedUser": { - "$ref": "#/components/schemas/ReportResponseReportedUser" + "format": "date-time" }, - "history": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ReportResponseDecisionHistoryItem" - } - } - } - }, - "ReportResponseReportedUser": { - "type": "object", - "properties": { - "userId": { + "conversationId": { "type": "integer", "format": "int64" - }, - "nickname": { - "type": "string" - }, - "role": { - "type": "string" } } }, - "ReportResponseDecisionHistoryItem": { + "TalentInvitationPageResponseCompanyTalentInvitationResponse": { "type": "object", "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "fromStatus": { - "type": "string" - }, - "toStatus": { - "type": "string" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanyTalentInvitationResponse" + } }, - "actorUserId": { + "total": { "type": "integer", "format": "int64" }, - "actorRole": { - "type": "string" + "page": { + "type": "integer", + "format": "int32" }, - "note": { - "type": "string" + "size": { + "type": "integer", + "format": "int32" }, - "createdAt": { - "type": "string", - "format": "date-time" + "totalPages": { + "type": "integer", + "format": "int32" } } }, - "BlockRelationResponse": { + "TalentInvitationPageResponseStudentTalentInvitationResponse": { "type": "object", "properties": { - "id": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StudentTalentInvitationResponse" + } + }, + "total": { "type": "integer", "format": "int64" }, - "blockedUserId": { + "page": { "type": "integer", - "format": "int64" + "format": "int32" }, - "nickname": { - "type": "string" + "size": { + "type": "integer", + "format": "int32" }, - "createdAt": { - "type": "string", - "format": "date-time" + "totalPages": { + "type": "integer", + "format": "int32" } } }, - "ApiResponseReportResponse": { + "ApiResponseCompanyTalentInvitationResponse": { "type": "object", "properties": { "code": { - "type": "integer", - "format": "int32" + "type": "integer" }, "message": { "type": "string" }, "data": { - "$ref": "#/components/schemas/ReportResponse" + "$ref": "#/components/schemas/CompanyTalentInvitationResponse" }, "requestId": { "type": "string" @@ -13749,38 +17197,17 @@ } } }, - "ApiResponsePageResponseReportResponse": { + "ApiResponseStudentTalentInvitationResponse": { "type": "object", "properties": { "code": { - "type": "integer", - "format": "int32" + "type": "integer" }, "message": { "type": "string" }, "data": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ReportResponse" - } - }, - "totalElements": { - "type": "integer", - "format": "int64" - }, - "page": { - "type": "integer", - "format": "int32" - }, - "size": { - "type": "integer", - "format": "int32" - } - } + "$ref": "#/components/schemas/StudentTalentInvitationResponse" }, "requestId": { "type": "string" @@ -13791,18 +17218,17 @@ } } }, - "ApiResponseBlockRelationResponse": { + "ApiResponseCompanyTalentInvitationPageResponse": { "type": "object", "properties": { "code": { - "type": "integer", - "format": "int32" + "type": "integer" }, "message": { "type": "string" }, "data": { - "$ref": "#/components/schemas/BlockRelationResponse" + "$ref": "#/components/schemas/TalentInvitationPageResponseCompanyTalentInvitationResponse" }, "requestId": { "type": "string" @@ -13813,38 +17239,17 @@ } } }, - "ApiResponsePageResponseBlockRelationResponse": { + "ApiResponseStudentTalentInvitationPageResponse": { "type": "object", "properties": { "code": { - "type": "integer", - "format": "int32" + "type": "integer" }, "message": { "type": "string" }, "data": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BlockRelationResponse" - } - }, - "totalElements": { - "type": "integer", - "format": "int64" - }, - "page": { - "type": "integer", - "format": "int32" - }, - "size": { - "type": "integer", - "format": "int32" - } - } + "$ref": "#/components/schemas/TalentInvitationPageResponseStudentTalentInvitationResponse" }, "requestId": { "type": "string" @@ -13855,733 +17260,738 @@ } } }, - "CreateAbilityAttemptRequest": { + "FairnessResponseDistribution": { "type": "object", - "required": [ - "typeCode" - ], "properties": { - "typeCode": { - "type": "string" + "sampleSize": { + "type": "integer" }, - "idempotencyKey": { - "type": "string", - "maxLength": 64 - } - } - }, - "SaveAbilityAnswersRequest": { - "type": "object", - "required": [ - "answers" - ], - "properties": { - "answers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AbilityAnswerItem" - } - } - } - }, - "AbilityAnswerItem": { - "type": "object", - "required": [ - "attemptQuestionId", - "selectedOptionId" - ], - "properties": { - "attemptQuestionId": { - "type": "integer", - "format": "int64" + "mean": { + "type": "number" + }, + "median": { + "type": "number" + }, + "p10": { + "type": "number" + }, + "p25": { + "type": "number" + }, + "p75": { + "type": "number" + }, + "p90": { + "type": "number" + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" }, - "selectedOptionId": { - "type": "string", - "maxLength": 4 + "standardDeviation": { + "type": "number" + }, + "buckets": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "concentrationRate": { + "type": "number" + }, + "riskLevel": { + "type": "string" + }, + "availability": { + "type": "string" + }, + "reason": { + "type": "string" } } }, - "AbilityTypeResponse": { + "FairnessResponseDimensionMetric": { "type": "object", "properties": { - "typeCode": { + "dimension": { "type": "string" }, - "displayName": { - "type": "string" + "populationSize": { + "type": "integer" }, - "description": { + "rowCount": { + "type": "integer" + }, + "missingRowCount": { + "type": "integer" + }, + "validSampleSize": { + "type": "integer" + }, + "confidence": { "type": "string" }, - "questionCount": { - "type": "integer", - "format": "int32" + "missingRowRate": { + "type": "number" }, - "attemptQuestionCount": { - "type": "integer", - "format": "int32" + "nullScoreRate": { + "type": "number" }, - "timeLimitMinutes": { - "type": "integer", - "format": "int32" + "validCoverage": { + "type": "number" }, - "resultValidDays": { - "type": "integer", - "format": "int32" + "statusCounts": { + "type": "object", + "additionalProperties": { + "type": "integer" + } }, - "status": { + "versionCounts": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "inconsistentRecordCount": { + "type": "integer" + }, + "unknownStatusCount": { + "type": "integer" + }, + "outOfRangeScoreCount": { + "type": "integer" + }, + "unknownRulesVersionCount": { + "type": "integer" + }, + "distribution": { + "$ref": "#/components/schemas/FairnessResponseDistribution" + }, + "distributionsByRulesVersion": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/FairnessResponseDistribution" + } + }, + "availability": { + "type": "string" + }, + "reason": { "type": "string" } } }, - "AbilityAttemptResponse": { + "FairnessResponseOverview": { "type": "object", "properties": { - "attemptId": { - "type": "integer", - "format": "int64" + "basis": { + "type": "string" }, - "status": { + "scope": { "type": "string" }, - "answeredCount": { - "type": "integer", - "format": "int32" + "period": { + "type": "string" }, - "totalQuestions": { - "type": "integer", - "format": "int32" + "capturedAt": { + "type": "string", + "format": "date-time" }, - "completionRate": { + "populationSize": { + "type": "integer" + }, + "computableSnapshotRatio": { "type": "number" }, - "startedAt": { - "type": "string", - "format": "date-time", - "nullable": true + "confidence": { + "type": "string" }, - "expiresAt": { - "type": "string", - "format": "date-time", - "nullable": true + "riskLevel": { + "type": "string" }, - "remainingSeconds": { - "type": "integer", - "format": "int64", - "nullable": true + "versionCounts": { + "type": "object", + "additionalProperties": { + "type": "integer" + } }, - "questions": { + "mixedVersionCount": { + "type": "integer" + }, + "unknownVersionCount": { + "type": "integer" + }, + "distribution": { + "$ref": "#/components/schemas/FairnessResponseDistribution" + }, + "distributionsByRulesVersion": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/FairnessResponseDistribution" + } + }, + "coherentVersionSubset": { "type": "array", "items": { - "$ref": "#/components/schemas/AbilityAttemptQuestionView" + "type": "string" } + }, + "availability": { + "type": "string" + }, + "reason": { + "type": "string" } } }, - "AbilityAttemptQuestionView": { + "FairnessResponseDimensions": { "type": "object", "properties": { - "attemptQuestionId": { - "type": "integer", - "format": "int64" - }, - "position": { - "type": "integer", - "format": "int32" + "basis": { + "type": "string" }, - "itemType": { + "scope": { "type": "string" }, - "stem": { + "period": { "type": "string" }, - "options": { + "capturedAt": { + "type": "string", + "format": "date-time" + }, + "dimensions": { "type": "array", "items": { - "$ref": "#/components/schemas/AbilityOptionView" + "$ref": "#/components/schemas/FairnessResponseDimensionMetric" } }, - "selectedOptionId": { + "availability": { + "type": "string" + }, + "reason": { "type": "string" } } }, - "AbilityOptionView": { + "FairnessResponseUnavailable": { "type": "object", "properties": { - "optionId": { - "type": "string" + "availability": { + "type": "string", + "enum": [ + "UNAVAILABLE" + ] }, - "text": { + "reason": { "type": "string" + }, + "capturedAt": { + "type": "string", + "format": "date-time" } } }, - "AbilityResultResponse": { + "FairnessResponseAppeals": { "type": "object", "properties": { - "attemptId": { + "basis": { + "type": "string" + }, + "from": { + "type": "string", + "format": "date" + }, + "to": { + "type": "string", + "format": "date" + }, + "capturedAt": { + "type": "string", + "format": "date-time" + }, + "appealCount": { "type": "integer", - "format": "int64" + "description": "申诉事件数;与按独立候选人数统计的其他字段区分" }, - "typeCode": { - "type": "string" + "sampleSize": { + "type": "integer" }, - "status": { - "type": "string" + "statusCounts": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "statusEventCounts": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "statusCandidateCounts": { + "type": "object", + "additionalProperties": { + "type": "integer" + } }, - "validityStatus": { + "confidence": { "type": "string" }, - "validUntil": { - "type": "string", - "format": "date-time", - "nullable": true + "approvedEverCount": { + "type": "integer" }, - "dimensionScores": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AbilityDimensionScore" - } + "approvalRate": { + "type": "number" }, - "overallScore": { + "rollbackExecutionRate": { "type": "number" }, - "weaknesses": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AbilityWeakness" - } + "duplicateAppealCount": { + "type": "integer" }, - "improvementAreas": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AbilityImprovementArea" - } + "duplicateAppealRate": { + "type": "number" }, - "radar": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AbilityRadarEntry" - } + "comparableRollbackCount": { + "type": "integer", + "description": "可比 ROLLBACK 历史行数;仅唯一历史且分数/规则版本合法的申诉" }, - "answeredCount": { + "comparableRollbackCandidateCount": { "type": "integer", - "format": "int32" + "description": "可比回滚历史对应的独立候选人数;同一候选人的多份合法申诉只计一人" }, - "completionRate": { + "changedCount": { + "type": "integer" + }, + "changedCandidateCount": { + "type": "integer" + }, + "actualScoreChangeRate": { "type": "number" }, - "questionFeedback": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AbilityQuestionFeedback" + "meanDelta": { + "type": "number" + }, + "meanAbsoluteDelta": { + "type": "number" + }, + "duplicateRollbackHistoryCount": { + "type": "integer" + }, + "invalidScoreRollbackCount": { + "type": "integer" + }, + "ruleVersionChangedRollbackCount": { + "type": "integer" + }, + "reasonCounts": { + "type": "object", + "additionalProperties": { + "type": "integer" } }, - "questionSetVersion": { + "availability": { "type": "string" }, - "rulesVersion": { + "reason": { "type": "string" }, - "generatedAt": { - "type": "string", - "format": "date-time" + "exposureAppealRateAvailability": { + "type": "string" }, - "disclaimer": { + "exposureAppealRateReason": { "type": "string" } } }, - "AbilityDimensionScore": { + "ApiResponseFairnessResponseOverview": { "type": "object", "properties": { "code": { + "type": "integer" + }, + "message": { "type": "string" }, - "score": { - "type": "number" + "data": { + "$ref": "#/components/schemas/FairnessResponseOverview" }, - "answeredCount": { - "type": "integer", - "format": "int32" + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "AbilityWeakness": { + "ApiResponseFairnessResponseDimensions": { "type": "object", "properties": { "code": { + "type": "integer" + }, + "message": { "type": "string" }, - "score": { - "type": "number" + "data": { + "$ref": "#/components/schemas/FairnessResponseDimensions" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "AbilityImprovementArea": { + "ApiResponseFairnessResponseUnavailable": { "type": "object", "properties": { - "dimension": { + "code": { + "type": "integer" + }, + "message": { "type": "string" }, - "currentScore": { - "type": "number" + "data": { + "$ref": "#/components/schemas/FairnessResponseUnavailable" }, - "summary": { + "requestId": { "type": "string" }, - "actions": { - "type": "array", - "items": { - "type": "string" - } + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "AbilityRadarEntry": { + "ApiResponseFairnessResponseAppeals": { "type": "object", "properties": { "code": { + "type": "integer" + }, + "message": { "type": "string" }, - "value": { - "type": "number" + "data": { + "$ref": "#/components/schemas/FairnessResponseAppeals" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "AbilityQuestionFeedback": { + "CandidateScoringCoverage": { "type": "object", "properties": { - "attemptQuestionId": { - "type": "integer", - "format": "int64" - }, - "position": { + "computableDimensions": { "type": "integer", "format": "int32" }, - "stem": { - "type": "string" - }, - "selectedOptionId": { - "type": "string" - }, - "awardedScore": { + "totalDimensions": { "type": "integer", "format": "int32" }, - "maxScore": { - "type": "integer", - "format": "int32" + "includedDimensions": { + "type": "array", + "items": { + "type": "string" + } }, - "explanation": { - "type": "string" + "excludedDimensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CandidateScoringExcludedDimension" + } } } }, - "AbilityHistoryItem": { + "CandidateScoringExcludedDimension": { "type": "object", "properties": { - "attemptId": { - "type": "integer", - "format": "int64" - }, - "typeCode": { + "dimension": { "type": "string" }, "status": { "type": "string" }, - "validityStatus": { + "reason": { "type": "string" - }, - "overallScore": { - "type": "number" - }, - "answeredCount": { - "type": "integer", - "format": "int32" - }, - "completionRate": { - "type": "number" - }, - "startedAt": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "expiresAt": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "completedAt": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "validUntil": { - "type": "string", - "format": "date-time", - "nullable": true } } }, - "ApiResponseAbilityTypeResponse": { + "CandidateScoringRuleContribution": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { + "ruleCode": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/AbilityTypeResponse" + "outcome": { + "type": "string", + "enum": [ + "APPLIED", + "NOT_APPLIED", + "MISSING", + "EXPIRED", + "UNAVAILABLE" + ] + }, + "contribution": { + "type": "number", + "nullable": true + }, + "maxContribution": { + "type": "number", + "nullable": true + }, + "label": { + "type": "string" }, - "requestId": { + "observation": { "type": "string" }, - "timestamp": { - "type": "string", - "format": "date-time" + "explanation": { + "type": "string" } } }, - "ApiResponseAbilityAttemptResponse": { + "CandidateScoringValidity": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/AbilityAttemptResponse" - }, - "requestId": { - "type": "string" + "completedAt": { + "type": "string", + "format": "date-time" }, - "timestamp": { + "validUntil": { "type": "string", "format": "date-time" + }, + "expired": { + "type": "boolean" } } }, - "ApiResponseAbilityResultResponse": { + "StudentProfileResponse": { "type": "object", "properties": { - "code": { + "id": { "type": "integer", - "format": "int32" + "format": "int64" }, - "message": { + "userId": { + "type": "integer", + "format": "int64" + }, + "realName": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/AbilityResultResponse" + "bio": { + "type": "string" }, - "requestId": { + "location": { "type": "string" }, - "timestamp": { - "type": "string", - "format": "date-time" - } - } - }, - "ApiResponsePageResponseAbilityHistoryItem": { - "type": "object", - "properties": { - "code": { + "education": { + "type": "string" + }, + "school": { + "type": "string" + }, + "graduationYear": { "type": "integer", "format": "int32" }, - "message": { + "resumeUrl": { "type": "string" }, - "data": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AbilityHistoryItem" - } - }, - "totalElements": { - "type": "integer", - "format": "int64" - }, - "page": { - "type": "integer", - "format": "int32" - }, - "size": { - "type": "integer", - "format": "int32" - } - } - }, - "requestId": { + "skills": { "type": "string" }, - "timestamp": { + "createdAt": { "type": "string", "format": "date-time" } } }, - "RiskRuleResponse": { + "CreateDataExportRequest": { "type": "object", "properties": { - "ruleCode": { - "type": "string" - }, - "sourceAction": { - "type": "string" - }, - "thresholdCount": { - "type": "integer", - "format": "int32" - }, - "windowMinutes": { - "type": "integer", - "format": "int32" - }, - "severity": { + "format": { "type": "string" }, - "enabled": { - "type": "boolean" - }, - "version": { - "type": "integer", - "format": "int64" + "idempotencyKey": { + "type": "string", + "maxLength": 64 } } }, - "RiskEventResponse": { + "DataExportTaskResponse": { "type": "object", "properties": { - "eventId": { + "taskId": { "type": "integer", "format": "int64" }, - "ruleCode": { - "type": "string" - }, - "sourceAction": { - "type": "string" - }, - "severity": { + "status": { "type": "string" }, - "status": { + "format": { "type": "string" }, - "occurrenceCount": { - "type": "integer", - "format": "int32" + "requestedAt": { + "type": "string", + "format": "date-time" }, - "windowStartedAt": { + "startedAt": { "type": "string", "format": "date-time" }, - "windowEndedAt": { + "completedAt": { "type": "string", "format": "date-time" }, - "detectedAt": { + "expiresAt": { "type": "string", "format": "date-time" }, - "version": { + "sizeBytes": { "type": "integer", "format": "int64" + }, + "downloadAvailable": { + "type": "boolean" + }, + "errorCode": { + "type": "string" } } }, - "RiskSeverityCount": { + "ApiResponseStudentProfileResponse": { "type": "object", "properties": { - "severity": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { "type": "string" }, - "count": { - "type": "integer", - "format": "int64" - } - } - }, - "RiskRuleCount": { - "type": "object", - "properties": { - "ruleCode": { + "data": { + "$ref": "#/components/schemas/StudentProfileResponse" + }, + "requestId": { "type": "string" }, - "count": { - "type": "integer", - "format": "int64" + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "RiskOverviewResponse": { + "ApiResponseDataExportTaskResponse": { "type": "object", "properties": { - "openCount": { + "code": { "type": "integer", - "format": "int64" + "format": "int32" }, - "bySeverity": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RiskSeverityCount" - } + "message": { + "type": "string" }, - "byRule": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RiskRuleCount" - } + "data": { + "$ref": "#/components/schemas/DataExportTaskResponse" }, - "lastDetectedAt": { + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", - "format": "date-time", - "nullable": true + "format": "date-time" } } }, - "RiskTrendPoint": { + "MessageResponse": { "type": "object", + "description": "脱敏消息响应;TALENT_INVITATION 使用 senderScope 而非 senderUserId", "properties": { - "time": { - "type": "string", - "format": "date-time" + "id": { + "type": "integer", + "format": "int64" }, - "count": { + "conversationId": { "type": "integer", "format": "int64" - } - } - }, - "RiskTrendResponse": { - "type": "object", - "properties": { - "bucket": { + }, + "senderUserId": { + "type": "integer", + "format": "int64" + }, + "senderScope": { "type": "string" }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RiskTrendPoint" - } - } - } - }, - "RiskDistributionRow": { - "type": "object", - "properties": { - "ruleCode": { + "content": { "type": "string" }, - "severity": { - "type": "string" + "status": { + "type": "string", + "enum": [ + "SENT", + "RECALLED" + ] + }, + "recalledAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" }, - "count": { - "type": "integer", - "format": "int64" + "isRead": { + "type": "boolean" } } }, - "RiskDistributionResponse": { + "MessagePageResponse": { "type": "object", + "description": "脱敏消息分页响应", "properties": { "items": { "type": "array", "items": { - "$ref": "#/components/schemas/RiskDistributionRow" + "$ref": "#/components/schemas/MessageResponse" } - } - } - }, - "RiskRuleUpdateRequest": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" }, - "thresholdCount": { + "total": { "type": "integer", - "format": "int32" + "format": "int64" }, - "windowMinutes": { + "page": { "type": "integer", "format": "int32" }, - "severity": { - "type": "string" - }, - "expectedVersion": { + "size": { "type": "integer", - "format": "int64" - } - }, - "required": [ - "enabled", - "thresholdCount", - "windowMinutes", - "severity", - "expectedVersion" - ] - }, - "RiskEventActionRequest": { - "type": "object", - "properties": { - "action": { - "type": "string" - }, - "reasonCode": { - "type": "string" + "format": "int32" }, - "expectedVersion": { - "type": "integer", - "format": "int64" - } - }, - "required": [ - "action", - "reasonCode", - "expectedVersion" - ] - }, - "ApiResponseRiskOverviewResponse": { - "type": "object", - "properties": { - "code": { + "totalPages": { "type": "integer", "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/RiskOverviewResponse" - }, - "requestId": { - "type": "string" - }, - "timestamp": { - "type": "string", - "format": "date-time" } } }, - "ApiResponseRiskTrendResponse": { + "ApiResponseMessagePageResponse": { "type": "object", "properties": { "code": { @@ -14592,7 +18002,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/RiskTrendResponse" + "$ref": "#/components/schemas/MessagePageResponse" }, "requestId": { "type": "string" @@ -14603,7 +18013,7 @@ } } }, - "ApiResponseRiskDistributionResponse": { + "ApiResponseMessageResponse": { "type": "object", "properties": { "code": { @@ -14614,7 +18024,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/RiskDistributionResponse" + "$ref": "#/components/schemas/MessageResponse" }, "requestId": { "type": "string" @@ -14625,7 +18035,7 @@ } } }, - "ApiResponsePageResponseRiskEventResponse": { + "ApiResponsePageResponseDataExportTaskResponse": { "type": "object", "properties": { "code": { @@ -14641,7 +18051,7 @@ "items": { "type": "array", "items": { - "$ref": "#/components/schemas/RiskEventResponse" + "$ref": "#/components/schemas/DataExportTaskResponse" } }, "totalElements": { @@ -14667,199 +18077,135 @@ } } }, - "ApiResponseListRiskRuleResponse": { + "RefreshTokenRequest": { "type": "object", + "required": [ + "refreshToken" + ], "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RiskRuleResponse" - } - }, - "requestId": { - "type": "string" - }, - "timestamp": { + "refreshToken": { "type": "string", - "format": "date-time" + "minLength": 1, + "maxLength": 4096 } } }, - "ApiResponseRiskRuleResponse": { + "TokenPairResponse": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { + "accessToken": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/RiskRuleResponse" - }, - "requestId": { + "refreshToken": { "type": "string" }, - "timestamp": { - "type": "string", - "format": "date-time" + "expiresIn": { + "type": "integer", + "format": "int64" } } }, - "ApiResponseRiskEventResponse": { + "ApiResponseTokenPairResponse": { "type": "object", "properties": { "code": { - "type": "integer", - "format": "int32" + "type": "integer" }, "message": { "type": "string" }, "data": { - "$ref": "#/components/schemas/RiskEventResponse" - }, - "requestId": { - "type": "string" - }, - "timestamp": { - "type": "string", - "format": "date-time" + "$ref": "#/components/schemas/TokenPairResponse" } } }, - "TalentDiscoveryPreferenceResponse": { + "PasswordSetRequest": { "type": "object", "properties": { - "enabled": { - "type": "boolean" - }, - "version": { - "type": "integer", - "format": "int64" - }, - "createdAt": { + "smsCode": { "type": "string", - "format": "date-time" + "minLength": 1 }, - "updatedAt": { + "newPassword": { "type": "string", - "format": "date-time" + "minLength": 1 } - } + }, + "required": [ + "newPassword", + "smsCode" + ] }, - "TalentDiscoveryPreferenceUpdateRequest": { + "PasswordResetRequest": { "type": "object", - "required": [ - "enabled" - ], "properties": { - "enabled": { - "type": "boolean" + "phone": { + "type": "string", + "minLength": 1, + "pattern": "[0-9]{11}" }, - "expectedVersion": { - "type": "integer", - "format": "int64", - "minimum": 0 + "smsCode": { + "type": "string", + "minLength": 1 + }, + "newPassword": { + "type": "string", + "minLength": 1 } - } + }, + "required": [ + "newPassword", + "phone", + "smsCode" + ] }, - "TalentDiscoveryCandidateResponse": { + "PasswordLoginRequest": { "type": "object", "properties": { - "candidateKey": { - "type": "string" - }, - "candidateLabel": { - "type": "string" - }, - "city": { - "type": "string" - }, - "education": { - "type": "string" - }, - "graduationYear": { - "type": "integer", - "format": "int32" - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } - }, - "fitBand": { + "phone": { "type": "string", - "enum": [ - "HIGH", - "MEDIUM", - "BASIC" - ] - }, - "summary": { - "type": "string" + "minLength": 1, + "pattern": "[0-9]{11}" }, - "signalsAvailable": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "PREFERENCE", - "RIASEC", - "ABILITY" - ] - } + "password": { + "type": "string", + "minLength": 1 } - } + }, + "required": [ + "password", + "phone" + ] }, - "TalentDiscoveryPageResponse": { + "PasswordChangeRequest": { "type": "object", "properties": { - "mode": { + "currentPassword": { "type": "string", - "enum": [ - "PERSONALIZED", - "UNAVAILABLE", - "NO_ELIGIBLE_CANDIDATE" - ] - }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TalentDiscoveryCandidateResponse" - } - }, - "total": { - "type": "integer", - "format": "int64" - }, - "page": { - "type": "integer", - "format": "int32" - }, - "size": { - "type": "integer", - "format": "int32" + "minLength": 1 }, - "totalPages": { - "type": "integer", - "format": "int32" + "newPassword": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "currentPassword", + "newPassword" + ] + }, + "PasswordOperationResponse": { + "type": "object", + "properties": { + "passwordConfigured": { + "type": "boolean" }, - "message": { - "type": "string" + "reauthenticationRequired": { + "type": "boolean" } } }, - "ApiResponseTalentDiscoveryPreferenceResponse": { + "ApiResponsePasswordOperationResponse": { "type": "object", "properties": { "code": { @@ -14870,7 +18216,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/TalentDiscoveryPreferenceResponse" + "$ref": "#/components/schemas/PasswordOperationResponse" }, "requestId": { "type": "string" @@ -14881,233 +18227,254 @@ } } }, - "ApiResponseTalentDiscoveryPageResponse": { + "CompanyDetails": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" + "name": { + "type": "string", + "maxLength": 100, + "minLength": 0 }, - "message": { + "logoUrl": { + "type": "string", + "maxLength": 500, + "minLength": 0 + }, + "description": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/TalentDiscoveryPageResponse" + "industry": { + "type": "string", + "maxLength": 80, + "minLength": 0 }, - "requestId": { - "type": "string" + "website": { + "type": "string", + "maxLength": 255, + "minLength": 0 }, - "timestamp": { + "address": { "type": "string", - "format": "date-time" + "maxLength": 255, + "minLength": 0 } - } + }, + "required": [ + "name" + ] }, - "CreateTalentInvitationRequest": { + "CompanyRegistrationRequest": { "type": "object", - "required": [ - "candidateKey", - "message", - "idempotencyKey" - ], "properties": { - "candidateKey": { + "phone": { "type": "string", - "maxLength": 128 + "minLength": 1, + "pattern": "[0-9]{11}" }, - "message": { + "code": { "type": "string", "minLength": 1, - "maxLength": 300 + "pattern": "[0-9]{6}" }, - "idempotencyKey": { + "password": { "type": "string", - "format": "uuid", - "maxLength": 64 + "maxLength": 64, + "minLength": 12 + }, + "company": { + "$ref": "#/components/schemas/CompanyDetails" } - } + }, + "required": [ + "code", + "company", + "password", + "phone" + ] }, - "TalentInvitationDecisionRequest": { + "ApiResponseCompanyRegistrationResponse": { "type": "object", - "required": [ - "decision", - "expectedVersion" - ], "properties": { - "decision": { - "type": "string", - "enum": [ - "ACCEPT", - "REJECT" - ] - }, - "expectedVersion": { + "code": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CompanyRegistrationResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "CompanyTalentInvitationResponse": { + "CompanyRegistrationResponse": { "type": "object", "properties": { - "invitationId": { + "userId": { "type": "integer", "format": "int64" }, - "jobId": { + "companyId": { "type": "integer", "format": "int64" }, - "jobTitle": { + "role": { "type": "string" }, - "candidateLabel": { + "onboardingStatus": { + "type": "string" + }, + "accessToken": { + "type": "string" + }, + "refreshToken": { "type": "string" }, + "expiresIn": { + "type": "integer", + "format": "int64" + } + } + }, + "ApiResponseCompanyOnboardingResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, "message": { "type": "string" }, - "status": { - "type": "string", - "enum": [ - "PENDING", - "ACCEPTED", - "REJECTED", - "EXPIRED" - ] + "data": { + "$ref": "#/components/schemas/CompanyOnboardingResponse" }, - "createdAt": { - "type": "string", - "format": "date-time" + "requestId": { + "type": "string" }, - "expiresAt": { + "timestamp": { "type": "string", "format": "date-time" + } + } + }, + "CompanyOnboardingResponse": { + "type": "object", + "properties": { + "company": { + "$ref": "#/components/schemas/CompanySummary" }, - "decidedAt": { - "type": "string", - "format": "date-time" + "onboardingStatus": { + "type": "string" }, - "conversationId": { - "type": "integer", - "format": "int64" + "latestVerification": { + "$ref": "#/components/schemas/VerificationSummary" + }, + "missingFields": { + "type": "array", + "items": { + "type": "string" + } + }, + "allowedActions": { + "type": "array", + "items": { + "type": "string" + } } } }, - "StudentTalentInvitationResponse": { + "CompanySummary": { "type": "object", "properties": { - "invitationId": { - "type": "integer", - "format": "int64" - }, - "companyId": { + "id": { "type": "integer", "format": "int64" }, - "companyName": { + "name": { "type": "string" }, - "jobId": { - "type": "integer", - "format": "int64" + "logoUrl": { + "type": "string" }, - "jobTitle": { + "description": { "type": "string" }, - "jobCity": { + "industry": { "type": "string" }, - "message": { + "website": { "type": "string" }, - "status": { - "type": "string", - "enum": [ - "PENDING", - "ACCEPTED", - "REJECTED", - "EXPIRED" - ] + "address": { + "type": "string" }, - "version": { + "verified": { + "type": "boolean" + } + } + }, + "VerificationSummary": { + "type": "object", + "properties": { + "id": { "type": "integer", "format": "int64" }, - "createdAt": { - "type": "string", - "format": "date-time" + "status": { + "type": "string" }, - "expiresAt": { + "submittedAt": { "type": "string", "format": "date-time" }, "decidedAt": { "type": "string", "format": "date-time" - }, - "conversationId": { - "type": "integer", - "format": "int64" } } }, - "TalentInvitationPageResponseCompanyTalentInvitationResponse": { + "LxqSkillCloudNode": { "type": "object", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CompanyTalentInvitationResponse" - } - }, - "total": { + "id": { "type": "integer", "format": "int64" }, - "page": { - "type": "integer", - "format": "int32" - }, - "size": { - "type": "integer", - "format": "int32" + "code": { + "type": "string" }, - "totalPages": { - "type": "integer", - "format": "int32" + "name": { + "type": "string" } } }, - "TalentInvitationPageResponseStudentTalentInvitationResponse": { + "LxqApiResponseVoid": { "type": "object", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StudentTalentInvitationResponse" - } - }, - "total": { - "type": "integer", - "format": "int64" + "code": { + "type": "integer" }, - "page": { - "type": "integer", - "format": "int32" + "message": { + "type": "string" }, - "size": { - "type": "integer", - "format": "int32" + "requestId": { + "type": "string" }, - "totalPages": { - "type": "integer", - "format": "int32" + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "ApiResponseCompanyTalentInvitationResponse": { + "LxqApiResponseSalaryCityDetailResponse": { "type": "object", "properties": { "code": { @@ -15117,7 +18484,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/CompanyTalentInvitationResponse" + "$ref": "#/components/schemas/LxqSalaryCityDetailResponse" }, "requestId": { "type": "string" @@ -15128,17 +18495,18 @@ } } }, - "ApiResponseStudentTalentInvitationResponse": { + "LxqApiResponseCompanyKnowledgeOverviewResponse": { "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "int32" }, "message": { "type": "string" }, "data": { - "$ref": "#/components/schemas/StudentTalentInvitationResponse" + "$ref": "#/components/schemas/LxqCompanyKnowledgeOverviewResponse" }, "requestId": { "type": "string" @@ -15149,7 +18517,54 @@ } } }, - "ApiResponseCompanyTalentInvitationPageResponse": { + "LxqCompanyKnowledgeUpdateRequest": { + "type": "object", + "properties": { + "culture": { + "type": "string", + "maxLength": 2000, + "minLength": 0 + }, + "workPolicies": { + "type": "string", + "maxLength": 4000, + "minLength": 0 + }, + "onboardingGuide": { + "type": "string", + "maxLength": 4000, + "minLength": 0 + }, + "version": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "LxqKnowledgeView": { + "type": "object", + "properties": { + "culture": { + "type": "string" + }, + "workPolicies": { + "type": "string" + }, + "onboardingGuide": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "version": { + "type": "integer", + "format": "int64" + } + } + }, + "LxqApiResponseListSalaryCitySummaryResponse": { "type": "object", "properties": { "code": { @@ -15159,7 +18574,10 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/TalentInvitationPageResponseCompanyTalentInvitationResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/LxqSalaryCitySummaryResponse" + } }, "requestId": { "type": "string" @@ -15170,7 +18588,7 @@ } } }, - "ApiResponseStudentTalentInvitationPageResponse": { + "LxqApiResponseSalaryHistoryDetailResponse": { "type": "object", "properties": { "code": { @@ -15180,7 +18598,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/TalentInvitationPageResponseStudentTalentInvitationResponse" + "$ref": "#/components/schemas/LxqSalaryHistoryDetailResponse" }, "requestId": { "type": "string" @@ -15191,428 +18609,393 @@ } } }, - "FairnessResponseDistribution": { + "LxqSkillLearningPathResponse": { "type": "object", "properties": { - "sampleSize": { - "type": "integer" - }, - "mean": { - "type": "number" - }, - "median": { - "type": "number" - }, - "p10": { - "type": "number" - }, - "p25": { - "type": "number" - }, - "p75": { - "type": "number" - }, - "p90": { - "type": "number" - }, - "min": { - "type": "number" - }, - "max": { - "type": "number" - }, - "standardDeviation": { - "type": "number" + "total": { + "type": "integer", + "format": "int32", + "minimum": 0 }, - "buckets": { - "type": "object", - "additionalProperties": { - "type": "integer" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LxqSkillLearningPathItem" } - }, - "concentrationRate": { - "type": "number" - }, - "riskLevel": { - "type": "string" - }, - "availability": { - "type": "string" - }, - "reason": { - "type": "string" } } }, - "FairnessResponseDimensionMetric": { + "LxqCompanyChecklistItemView": { "type": "object", "properties": { - "dimension": { - "type": "string" - }, - "populationSize": { - "type": "integer" - }, - "rowCount": { - "type": "integer" - }, - "missingRowCount": { - "type": "integer" - }, - "validSampleSize": { - "type": "integer" + "itemId": { + "type": "integer", + "format": "int64" }, - "confidence": { + "source": { "type": "string" }, - "missingRowRate": { - "type": "number" - }, - "nullScoreRate": { - "type": "number" - }, - "validCoverage": { - "type": "number" - }, - "statusCounts": { - "type": "object", - "additionalProperties": { - "type": "integer" - } - }, - "versionCounts": { - "type": "object", - "additionalProperties": { - "type": "integer" - } - }, - "inconsistentRecordCount": { - "type": "integer" + "itemKey": { + "type": "string" }, - "unknownStatusCount": { - "type": "integer" + "title": { + "type": "string" }, - "outOfRangeScoreCount": { - "type": "integer" + "description": { + "type": "string" }, - "unknownRulesVersionCount": { - "type": "integer" + "category": { + "type": "string" }, - "distribution": { - "$ref": "#/components/schemas/FairnessResponseDistribution" + "required": { + "type": "boolean" }, - "distributionsByRulesVersion": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/FairnessResponseDistribution" - } + "sortOrder": { + "type": "integer", + "format": "int32" }, - "availability": { - "type": "string" + "active": { + "type": "boolean" }, - "reason": { - "type": "string" + "version": { + "type": "integer", + "format": "int64" } } }, - "FairnessResponseOverview": { + "LxqCompanyChecklistItemCreateRequest": { "type": "object", "properties": { - "basis": { - "type": "string" - }, - "scope": { - "type": "string" - }, - "period": { - "type": "string" - }, - "capturedAt": { + "title": { "type": "string", - "format": "date-time" - }, - "populationSize": { - "type": "integer" + "maxLength": 120, + "minLength": 0 }, - "computableSnapshotRatio": { - "type": "number" + "description": { + "type": "string", + "maxLength": 1000, + "minLength": 0 }, - "confidence": { - "type": "string" + "category": { + "type": "string", + "minLength": 1, + "pattern": "DOCUMENT|ADMIN|EQUIPMENT|TRAINING|LOGISTICS|OTHER" }, - "riskLevel": { - "type": "string" + "sortOrder": { + "type": "integer", + "format": "int32", + "default": 1000, + "maximum": 9999, + "minimum": 0 }, - "versionCounts": { - "type": "object", - "additionalProperties": { - "type": "integer" - } + "required": { + "type": "boolean", + "default": false }, - "mixedVersionCount": { - "type": "integer" + "idempotencyKey": { + "type": "string", + "maxLength": 64, + "minLength": 0 + } + }, + "required": [ + "category", + "title" + ] + }, + "LxqSkillMatchResponse": { + "type": "object", + "properties": { + "matchScore": { + "type": "integer", + "format": "int32", + "minimum": 0, + "maximum": 100, + "description": "round(matchedCount*100/min(10,total));total=0 时为 0。" }, - "unknownVersionCount": { - "type": "integer" + "matchedCount": { + "type": "integer", + "format": "int32", + "minimum": 0 }, - "distribution": { - "$ref": "#/components/schemas/FairnessResponseDistribution" + "totalCount": { + "type": "integer", + "format": "int32", + "minimum": 0, + "description": "实际匹配集合大小 min(10,total)。" }, - "distributionsByRulesVersion": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/FairnessResponseDistribution" + "matchedSkills": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LxqSkillCloudItem" } }, - "coherentVersionSubset": { + "missingSkills": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/LxqSkillCloudItem" } - }, - "availability": { - "type": "string" - }, - "reason": { - "type": "string" } } }, - "FairnessResponseDimensions": { + "LxqSalaryCitySummaryResponse": { "type": "object", "properties": { - "basis": { + "cityCode": { "type": "string" }, - "scope": { + "provinceCode": { "type": "string" }, - "period": { + "provinceName": { "type": "string" }, - "capturedAt": { - "type": "string", - "format": "date-time" - }, - "dimensions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FairnessResponseDimensionMetric" - } - }, - "availability": { + "cityName": { "type": "string" }, - "reason": { + "policyVersion": { "type": "string" + }, + "policyEffectiveDate": { + "type": "string", + "format": "date" + }, + "dataLevel": { + "$ref": "#/components/schemas/LxqSalaryDataLevel" } } }, - "FairnessResponseUnavailable": { + "LxqSkillCloudResponse": { "type": "object", "properties": { - "availability": { - "type": "string", - "enum": [ - "UNAVAILABLE" - ] + "industry": { + "$ref": "#/components/schemas/LxqSkillCloudNode" }, - "reason": { - "type": "string" + "occupation": { + "$ref": "#/components/schemas/LxqSkillCloudNode" }, - "capturedAt": { - "type": "string", - "format": "date-time" + "category": { + "$ref": "#/components/schemas/LxqSkillCloudNode", + "nullable": true + }, + "total": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LxqSkillCloudItem" + } } } }, - "FairnessResponseAppeals": { + "LxqSalaryCalculationRequest": { "type": "object", + "required": [ + "cityCode", + "grossMonthlySalary", + "expectedPolicyVersion" + ], "properties": { - "basis": { + "cityCode": { "type": "string" }, - "from": { - "type": "string", - "format": "date" + "grossMonthlySalary": { + "type": "number" }, - "to": { - "type": "string", - "format": "date" + "monthIndex": { + "type": "integer", + "minimum": 1, + "maximum": 12 }, - "capturedAt": { + "specialAdditionalDeduction": { + "type": "number" + }, + "expectedPolicyVersion": { + "type": "string" + }, + "idempotencyKey": { "type": "string", - "format": "date-time" + "maxLength": 64 }, - "appealCount": { - "type": "integer", - "description": "申诉事件数;与按独立候选人数统计的其他字段区分" + "overrides": { + "$ref": "#/components/schemas/LxqSalaryCalculationOverrideRequest" + } + } + }, + "LxqSalaryCalculationResult": { + "type": "object", + "properties": { + "cityCode": { + "type": "string" }, - "sampleSize": { - "type": "integer" + "cityName": { + "type": "string" }, - "statusCounts": { - "type": "object", - "additionalProperties": { - "type": "integer" - } + "dataLevel": { + "$ref": "#/components/schemas/LxqSalaryDataLevel" }, - "statusEventCounts": { - "type": "object", - "additionalProperties": { - "type": "integer" - } + "policyVersion": { + "type": "string" }, - "statusCandidateCounts": { - "type": "object", - "additionalProperties": { - "type": "integer" - } + "policyEffectiveDate": { + "type": "string", + "format": "date" }, - "confidence": { + "taxRulesVersion": { "type": "string" }, - "approvedEverCount": { + "estimate": { + "type": "boolean" + }, + "monthIndex": { "type": "integer" }, - "approvalRate": { + "grossMonthlySalary": { "type": "number" }, - "rollbackExecutionRate": { + "specialAdditionalDeduction": { "type": "number" }, - "duplicateAppealCount": { - "type": "integer" + "socialSecurityBase": { + "type": "number" }, - "duplicateAppealRate": { + "housingFundBase": { "type": "number" }, - "comparableRollbackCount": { - "type": "integer", - "description": "可比 ROLLBACK 历史行数;仅唯一历史且分数/规则版本合法的申诉" + "pensionRate": { + "type": "number" }, - "comparableRollbackCandidateCount": { - "type": "integer", - "description": "可比回滚历史对应的独立候选人数;同一候选人的多份合法申诉只计一人" + "medicalRate": { + "type": "number" }, - "changedCount": { - "type": "integer" + "unemploymentRate": { + "type": "number" }, - "changedCandidateCount": { - "type": "integer" + "housingFundRate": { + "type": "number" }, - "actualScoreChangeRate": { + "seriousIllnessMonthlyFee": { "type": "number" }, - "meanDelta": { + "pension": { "type": "number" }, - "meanAbsoluteDelta": { + "medical": { "type": "number" }, - "duplicateRollbackHistoryCount": { - "type": "integer" + "unemployment": { + "type": "number" }, - "invalidScoreRollbackCount": { - "type": "integer" + "housingFund": { + "type": "number" }, - "ruleVersionChangedRollbackCount": { - "type": "integer" + "cumulativeGrossIncome": { + "type": "number" }, - "reasonCounts": { - "type": "object", - "additionalProperties": { - "type": "integer" - } + "cumulativeSocialInsurance": { + "type": "number" }, - "availability": { - "type": "string" + "cumulativeHousingFund": { + "type": "number" }, - "reason": { - "type": "string" + "cumulativeSeriousIllness": { + "type": "number" }, - "exposureAppealRateAvailability": { - "type": "string" + "cumulativeBasicDeduction": { + "type": "number" }, - "exposureAppealRateReason": { - "type": "string" - } - } - }, - "ApiResponseFairnessResponseOverview": { - "type": "object", - "properties": { - "code": { - "type": "integer" + "cumulativeSpecialAdditionalDeduction": { + "type": "number" }, - "message": { - "type": "string" + "cumulativeTaxableIncome": { + "type": "number" }, - "data": { - "$ref": "#/components/schemas/FairnessResponseOverview" + "cumulativeTaxDue": { + "type": "number" }, - "requestId": { - "type": "string" + "previouslyWithheldTax": { + "type": "number" + }, + "currentMonthTax": { + "type": "number" }, - "timestamp": { - "type": "string", - "format": "date-time" + "netSalary": { + "type": "number" } } }, - "ApiResponseFairnessResponseDimensions": { + "LxqSalaryCityDetailResponse": { "type": "object", "properties": { - "code": { - "type": "integer" + "cityCode": { + "type": "string" }, - "message": { + "provinceCode": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/FairnessResponseDimensions" + "provinceName": { + "type": "string" }, - "requestId": { + "cityName": { "type": "string" }, - "timestamp": { + "policyVersion": { + "type": "string" + }, + "policyEffectiveDate": { "type": "string", - "format": "date-time" - } - } - }, - "ApiResponseFairnessResponseUnavailable": { - "type": "object", - "properties": { - "code": { - "type": "integer" + "format": "date" }, - "message": { + "dataLevel": { + "$ref": "#/components/schemas/LxqSalaryDataLevel" + }, + "dataSource": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/FairnessResponseUnavailable" + "socialSecurityBaseMin": { + "type": "number" }, - "requestId": { - "type": "string" + "socialSecurityBaseMax": { + "type": "number" }, - "timestamp": { - "type": "string", - "format": "date-time" + "housingFundBaseMin": { + "type": "number" + }, + "housingFundBaseMax": { + "type": "number" + }, + "pensionRate": { + "type": "number" + }, + "medicalRate": { + "type": "number" + }, + "unemploymentRate": { + "type": "number" + }, + "housingFundRate": { + "type": "number" + }, + "seriousIllnessMonthlyFee": { + "type": "number" } } }, - "ApiResponseFairnessResponseAppeals": { + "LxqApiResponseCompanyKnowledgeView": { "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "int32" }, "message": { "type": "string" }, "data": { - "$ref": "#/components/schemas/FairnessResponseAppeals" + "$ref": "#/components/schemas/LxqCompanyKnowledgeView" }, "requestId": { "type": "string" @@ -15623,306 +19006,273 @@ } } }, - "CandidateScoringCoverage": { + "LxqCompanyView": { "type": "object", "properties": { - "computableDimensions": { + "id": { "type": "integer", - "format": "int32" + "format": "int64" }, - "totalDimensions": { - "type": "integer", - "format": "int32" + "name": { + "type": "string" }, - "includedDimensions": { - "type": "array", - "items": { - "type": "string" - } + "logoUrl": { + "type": "string" }, - "excludedDimensions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CandidateScoringExcludedDimension" - } - } - } - }, - "CandidateScoringExcludedDimension": { - "type": "object", - "properties": { - "dimension": { + "description": { "type": "string" }, - "status": { + "industry": { "type": "string" }, - "reason": { + "website": { + "type": "string" + }, + "address": { "type": "string" + }, + "verified": { + "type": "boolean" } } }, - "CandidateScoringRuleContribution": { + "LxqDimensionSummaryView": { "type": "object", "properties": { - "ruleCode": { - "type": "string" - }, - "outcome": { - "type": "string", - "enum": [ - "APPLIED", - "NOT_APPLIED", - "MISSING", - "EXPIRED", - "UNAVAILABLE" - ] - }, - "contribution": { - "type": "number", - "nullable": true - }, - "maxContribution": { + "averageScore": { "type": "number", - "nullable": true + "format": "double" }, - "label": { - "type": "string" - }, - "observation": { - "type": "string" + "sampleCount": { + "type": "integer", + "format": "int64" }, - "explanation": { + "dataState": { "type": "string" } } }, - "CandidateScoringValidity": { + "LxqPageResponseSalaryHistorySummaryResponse": { "type": "object", "properties": { - "completedAt": { - "type": "string", - "format": "date-time" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LxqSalaryHistorySummaryResponse" + } }, - "validUntil": { - "type": "string", - "format": "date-time" + "total": { + "type": "integer", + "format": "int64" }, - "expired": { - "type": "boolean" + "page": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "totalPages": { + "type": "integer" } } }, - "StudentProfileResponse": { + "LxqProgressUpdateResponse": { "type": "object", "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "userId": { + "itemId": { "type": "integer", "format": "int64" }, - "realName": { - "type": "string" - }, - "bio": { - "type": "string" - }, - "location": { - "type": "string" + "completed": { + "type": "boolean" }, - "education": { - "type": "string" + "completedAt": { + "type": "string", + "format": "date-time" }, - "school": { - "type": "string" + "progressVersion": { + "type": "integer", + "format": "int64" }, - "graduationYear": { + "totalItems": { "type": "integer", "format": "int32" }, - "resumeUrl": { - "type": "string" - }, - "skills": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - } - } - }, - "CreateDataExportRequest": { - "type": "object", - "properties": { - "format": { - "type": "string" + "completedItems": { + "type": "integer", + "format": "int32" }, - "idempotencyKey": { - "type": "string", - "maxLength": 64 + "completionRate": { + "type": "number", + "format": "double" } } }, - "DataExportTaskResponse": { + "LxqCompanyChecklistItemUpdateRequest": { "type": "object", "properties": { - "taskId": { - "type": "integer", - "format": "int64" - }, - "status": { - "type": "string" - }, - "format": { - "type": "string" - }, - "requestedAt": { - "type": "string", - "format": "date-time" - }, - "startedAt": { + "title": { "type": "string", - "format": "date-time" + "maxLength": 120, + "minLength": 0 }, - "completedAt": { + "description": { "type": "string", - "format": "date-time" + "maxLength": 1000, + "minLength": 0 }, - "expiresAt": { + "category": { "type": "string", - "format": "date-time" + "minLength": 1, + "pattern": "DOCUMENT|ADMIN|EQUIPMENT|TRAINING|LOGISTICS|OTHER" }, - "sizeBytes": { + "sortOrder": { "type": "integer", - "format": "int64" + "format": "int32", + "maximum": 9999, + "minimum": 0 }, - "downloadAvailable": { + "required": { "type": "boolean" }, - "errorCode": { - "type": "string" + "active": { + "type": "boolean" + }, + "version": { + "type": "integer", + "format": "int64", + "minimum": 0 } - } + }, + "required": [ + "active", + "category", + "required", + "sortOrder", + "title", + "version" + ] }, - "ApiResponseStudentProfileResponse": { + "LxqCompanyKnowledgeView": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" + "configured": { + "type": "boolean" }, - "message": { + "culture": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/StudentProfileResponse" + "workPolicies": { + "type": "string" }, - "requestId": { + "onboardingGuide": { "type": "string" }, - "timestamp": { + "updatedAt": { "type": "string", "format": "date-time" + }, + "version": { + "type": "integer", + "format": "int64" } } }, - "ApiResponseDataExportTaskResponse": { + "LxqUnauthorizedResponse": { "type": "object", + "additionalProperties": false, + "required": [ + "code", + "message", + "timestamp" + ], "properties": { "code": { "type": "integer", - "format": "int32" + "format": "int32", + "enum": [ + 401 + ] }, "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/DataExportTaskResponse" - }, - "requestId": { - "type": "string" + "type": "string", + "enum": [ + "not authenticated" + ] }, "timestamp": { "type": "string", - "format": "date-time" + "enum": [ + "" + ] } } }, - "MessageResponse": { + "LxqSalaryHistorySummaryResponse": { "type": "object", - "description": "脱敏消息响应;TALENT_INVITATION 使用 senderScope 而非 senderUserId", "properties": { "id": { "type": "integer", "format": "int64" }, - "conversationId": { - "type": "integer", - "format": "int64" - }, - "senderUserId": { - "type": "integer", - "format": "int64" - }, - "senderScope": { + "cityCode": { "type": "string" }, - "content": { + "cityName": { "type": "string" }, - "status": { - "type": "string", - "enum": [ - "SENT", - "RECALLED" - ] + "dataLevel": { + "$ref": "#/components/schemas/LxqSalaryDataLevel" }, - "recalledAt": { - "type": "string", - "format": "date-time" + "grossMonthlySalary": { + "type": "number" + }, + "netSalary": { + "type": "number" + }, + "monthIndex": { + "type": "integer" }, "createdAt": { "type": "string", "format": "date-time" - }, - "isRead": { - "type": "boolean" } } }, - "MessagePageResponse": { + "LxqProgressUpdateRequest": { "type": "object", - "description": "脱敏消息分页响应", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MessageResponse" - } + "completed": { + "type": "boolean" }, - "total": { + "version": { "type": "integer", - "format": "int64" + "format": "int64", + "minimum": 0 + } + }, + "required": [ + "completed" + ] + }, + "LxqCompanyKnowledgeOverviewResponse": { + "type": "object", + "properties": { + "company": { + "$ref": "#/components/schemas/LxqCompanyView" }, - "page": { - "type": "integer", - "format": "int32" + "knowledgeState": { + "type": "string" }, - "size": { - "type": "integer", - "format": "int32" + "knowledge": { + "$ref": "#/components/schemas/LxqKnowledgeView" }, - "totalPages": { - "type": "integer", - "format": "int32" + "reviewSummary": { + "$ref": "#/components/schemas/LxqReviewSummaryView" } } }, - "ApiResponseMessagePageResponse": { + "LxqApiResponseEffectiveChecklistResponse": { "type": "object", "properties": { "code": { @@ -15933,7 +19283,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/MessagePageResponse" + "$ref": "#/components/schemas/LxqEffectiveChecklistResponse" }, "requestId": { "type": "string" @@ -15944,18 +19294,17 @@ } } }, - "ApiResponseMessageResponse": { + "LxqApiResponseSalaryCalculationResponse": { "type": "object", "properties": { "code": { - "type": "integer", - "format": "int32" + "type": "integer" }, "message": { "type": "string" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/LxqSalaryCalculationResponse" }, "requestId": { "type": "string" @@ -15966,177 +19315,180 @@ } } }, - "ApiResponsePageResponseDataExportTaskResponse": { + "LxqEffectiveChecklistResponse": { "type": "object", "properties": { - "code": { + "companyId": { "type": "integer", - "format": "int32" + "format": "int64" }, - "message": { + "companyName": { "type": "string" }, - "data": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DataExportTaskResponse" - } - }, - "totalElements": { - "type": "integer", - "format": "int64" - }, - "page": { - "type": "integer", - "format": "int32" - }, - "size": { - "type": "integer", - "format": "int32" - } - } + "customized": { + "type": "boolean" }, - "requestId": { - "type": "string" + "totalItems": { + "type": "integer", + "format": "int32" }, - "timestamp": { - "type": "string", - "format": "date-time" - } - } - }, - "RefreshTokenRequest": { - "type": "object", - "required": [ - "refreshToken" - ], - "properties": { - "refreshToken": { - "type": "string", - "minLength": 1, - "maxLength": 4096 + "completedItems": { + "type": "integer", + "format": "int32" + }, + "completionRate": { + "type": "number", + "format": "double" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LxqChecklistItemView" + } } } }, - "TokenPairResponse": { + "LxqSalaryHistoryDetailResponse": { "type": "object", "properties": { - "accessToken": { + "id": { + "type": "integer", + "format": "int64" + }, + "cityCode": { "type": "string" }, - "refreshToken": { + "cityName": { "type": "string" }, - "expiresIn": { - "type": "integer", - "format": "int64" + "dataLevel": { + "$ref": "#/components/schemas/LxqSalaryDataLevel" + }, + "policyVersion": { + "type": "string" + }, + "taxRulesVersion": { + "type": "string" + }, + "monthIndex": { + "type": "integer" + }, + "grossMonthlySalary": { + "type": "number" + }, + "netSalary": { + "type": "number" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "inputSnapshot": { + "type": "object" + }, + "policySnapshot": { + "type": "object" + }, + "resultSnapshot": { + "type": "object" } } }, - "ApiResponseTokenPairResponse": { + "LxqApiResponseListCompanyChecklistItemView": { "type": "object", "properties": { "code": { - "type": "integer" + "type": "integer", + "format": "int32" }, "message": { "type": "string" }, "data": { - "$ref": "#/components/schemas/TokenPairResponse" - } - } - }, - "PasswordSetRequest": { - "type": "object", - "properties": { - "smsCode": { - "type": "string", - "minLength": 1 + "type": "array", + "items": { + "$ref": "#/components/schemas/LxqCompanyChecklistItemView" + } }, - "newPassword": { + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", - "minLength": 1 + "format": "date-time" } - }, - "required": [ - "newPassword", - "smsCode" - ] + } }, - "PasswordResetRequest": { + "LxqApiResponsePageResponseSalaryHistorySummaryResponse": { "type": "object", "properties": { - "phone": { - "type": "string", - "minLength": 1, - "pattern": "[0-9]{11}" + "code": { + "type": "integer" }, - "smsCode": { - "type": "string", - "minLength": 1 + "message": { + "type": "string" }, - "newPassword": { - "type": "string", - "minLength": 1 - } - }, - "required": [ - "newPassword", - "phone", - "smsCode" - ] - }, - "PasswordLoginRequest": { - "type": "object", - "properties": { - "phone": { - "type": "string", - "minLength": 1, - "pattern": "[0-9]{11}" + "data": { + "$ref": "#/components/schemas/LxqPageResponseSalaryHistorySummaryResponse" }, - "password": { + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", - "minLength": 1 + "format": "date-time" } - }, - "required": [ - "password", - "phone" - ] + } }, - "PasswordChangeRequest": { + "LxqApiResponseSkillCloudResponse": { "type": "object", "properties": { - "currentPassword": { - "type": "string", - "minLength": 1 + "code": { + "type": "integer", + "format": "int32" }, - "newPassword": { + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/LxqSkillCloudResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", - "minLength": 1 + "format": "date-time" } - }, - "required": [ - "currentPassword", - "newPassword" - ] + } }, - "PasswordOperationResponse": { + "LxqSalaryCalculationOverrideRequest": { "type": "object", "properties": { - "passwordConfigured": { - "type": "boolean" + "socialSecurityBase": { + "type": "number" }, - "reauthenticationRequired": { - "type": "boolean" + "housingFundBase": { + "type": "number" + }, + "pensionRate": { + "type": "number" + }, + "medicalRate": { + "type": "number" + }, + "unemploymentRate": { + "type": "number" + }, + "housingFundRate": { + "type": "number" + }, + "seriousIllnessMonthlyFee": { + "type": "number" } } }, - "ApiResponsePasswordOperationResponse": { + "LxqApiResponse": { "type": "object", "properties": { "code": { @@ -16146,9 +19498,7 @@ "message": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/PasswordOperationResponse" - }, + "data": {}, "requestId": { "type": "string" }, @@ -16158,72 +19508,70 @@ } } }, - "CompanyDetails": { + "LxqSkillCloudItem": { "type": "object", "properties": { - "name": { - "type": "string", - "maxLength": 100, - "minLength": 0 + "rank": { + "type": "integer", + "format": "int32", + "minimum": 1, + "description": "当前筛选范围内的稳定排名,从 1 开始。" }, - "logoUrl": { - "type": "string", - "maxLength": 500, - "minLength": 0 + "skillId": { + "type": "integer", + "format": "int64" }, - "description": { + "skillCode": { "type": "string" }, - "industry": { - "type": "string", - "maxLength": 80, - "minLength": 0 + "nameZh": { + "type": "string" }, - "website": { + "nameEn": { "type": "string", - "maxLength": 255, - "minLength": 0 + "nullable": true }, - "address": { + "sourceType": { "type": "string", - "maxLength": 255, - "minLength": 0 - } - }, - "required": [ - "name" - ] - }, - "CompanyRegistrationRequest": { - "type": "object", - "properties": { - "phone": { + "enum": [ + "ESCO", + "O*NET" + ], + "description": "技能身份主来源。" + }, + "sourceCode": { "type": "string", - "minLength": 1, - "pattern": "[0-9]{11}" + "description": "ESCO URI 或 O*NET 三元组标识。" }, - "code": { + "categoryId": { + "type": "integer", + "format": "int64" + }, + "categoryName": { + "type": "string" + }, + "heat": { + "type": "number", + "nullable": true, + "description": "O*NET Importance;无映射为 null,且排在非空热度之后。" + }, + "heatSourceCode": { "type": "string", - "minLength": 1, - "pattern": "[0-9]{6}" + "nullable": true, + "description": "热度来源 SOC。" }, - "password": { + "heatElementId": { "type": "string", - "maxLength": 64, - "minLength": 12 + "nullable": true, + "description": "热度来源 O*NET Element ID。" }, - "company": { - "$ref": "#/components/schemas/CompanyDetails" + "heatProxy": { + "type": "boolean", + "description": "热度是否为代理映射。" } - }, - "required": [ - "code", - "company", - "password", - "phone" - ] + } }, - "ApiResponseCompanyRegistrationResponse": { + "LxqApiResponseCompanyChecklistItemView": { "type": "object", "properties": { "code": { @@ -16234,7 +19582,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/CompanyRegistrationResponse" + "$ref": "#/components/schemas/LxqCompanyChecklistItemView" }, "requestId": { "type": "string" @@ -16245,36 +19593,80 @@ } } }, - "CompanyRegistrationResponse": { + "LxqSkillLearningPathItem": { "type": "object", "properties": { - "userId": { + "priority": { + "type": "integer", + "format": "int32", + "minimum": 1 + }, + "skillId": { "type": "integer", "format": "int64" }, - "companyId": { + "skillName": { + "type": "string" + }, + "categoryId": { "type": "integer", "format": "int64" }, - "role": { + "categoryName": { "type": "string" }, - "onboardingStatus": { + "suggestion": { + "type": "string", + "description": "按技能分类生成的通用建议,不含课程、AI、链接、时长或进度。" + } + } + }, + "LxqChecklistItemView": { + "type": "object", + "properties": { + "itemId": { + "type": "integer", + "format": "int64" + }, + "source": { "type": "string" }, - "accessToken": { + "itemKey": { "type": "string" }, - "refreshToken": { + "title": { "type": "string" }, - "expiresIn": { + "description": { + "type": "string" + }, + "category": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "sortOrder": { + "type": "integer", + "format": "int32" + }, + "active": { + "type": "boolean" + }, + "completed": { + "type": "boolean" + }, + "completedAt": { + "type": "string", + "format": "date-time" + }, + "progressVersion": { "type": "integer", "format": "int64" } } }, - "ApiResponseCompanyOnboardingResponse": { + "LxqApiResponseProgressUpdateResponse": { "type": "object", "properties": { "code": { @@ -16285,7 +19677,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/CompanyOnboardingResponse" + "$ref": "#/components/schemas/LxqProgressUpdateResponse" }, "requestId": { "type": "string" @@ -16296,79 +19688,105 @@ } } }, - "CompanyOnboardingResponse": { + "LxqApiResponseSkillLearningPathResponse": { "type": "object", "properties": { - "company": { - "$ref": "#/components/schemas/CompanySummary" + "code": { + "type": "integer", + "format": "int32" }, - "onboardingStatus": { + "message": { "type": "string" }, - "latestVerification": { - "$ref": "#/components/schemas/VerificationSummary" + "data": { + "$ref": "#/components/schemas/LxqSkillLearningPathResponse" }, - "missingFields": { - "type": "array", - "items": { - "type": "string" - } + "requestId": { + "type": "string" }, - "allowedActions": { - "type": "array", - "items": { - "type": "string" - } + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "CompanySummary": { + "LxqSalaryCalculationResponse": { "type": "object", "properties": { - "id": { + "historyId": { "type": "integer", "format": "int64" }, - "name": { - "type": "string" - }, - "logoUrl": { - "type": "string" + "createdAt": { + "type": "string", + "format": "date-time" }, - "description": { - "type": "string" + "result": { + "$ref": "#/components/schemas/LxqSalaryCalculationResult" + } + } + }, + "LxqSalaryDataLevel": { + "type": "string", + "enum": [ + "CITY", + "PROVINCE_FALLBACK" + ] + }, + "LxqApiResponseSkillMatchResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" }, - "industry": { + "message": { "type": "string" }, - "website": { - "type": "string" + "data": { + "$ref": "#/components/schemas/LxqSkillMatchResponse" }, - "address": { + "requestId": { "type": "string" }, - "verified": { - "type": "boolean" + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "VerificationSummary": { + "LxqReviewSummaryView": { "type": "object", "properties": { - "id": { + "state": { + "type": "string" + }, + "totalReviews": { "type": "integer", - "format": "int64" + "format": "int32" }, - "status": { - "type": "string" + "averageRating": { + "type": "number", + "format": "double" }, - "submittedAt": { - "type": "string", - "format": "date-time" + "ratingDistribution": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } }, - "decidedAt": { - "type": "string", - "format": "date-time" + "topTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "dimensionSummary": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/LxqDimensionSummaryView" + } } } } diff --git a/docs/salary-calculator-api.md b/docs/salary-calculator-api.md new file mode 100644 index 0000000..3ad6a3f --- /dev/null +++ b/docs/salary-calculator-api.md @@ -0,0 +1,158 @@ +# 薪资计算器 API(N05) + +> Base URL:`/api/v1` +> 认证:`Authorization: Bearer ` +> 角色:当前登录用户必须具备 `STUDENT` 角色。 + +## 1. 能力边界 + +- 输入税前月薪,估算个人五险一金、大病固定费和当月个税后的到手工资。 +- 采用居民个人综合所得累计预扣法;`monthIndex` 表示当前工资口径下的累计计税月份数,而不是自然月编号。 +- 支持用户覆盖社保基数、公积金基数和个人比例。 +- 城市政策以地级行政区、直辖市和省直辖县级市为最小单元,不拆分到区县。 +- `dataLevel=CITY` 表示城市级参数;`dataLevel=PROVINCE_FALLBACK` 表示由于公开城市级资料不足而使用省级回退参数,结果精度较低。 +- 所有结果均为估算值,实际以工资单、当地政策和税务申报为准。 + +## 2. 查询城市列表 + +`GET /salary-calculator/cities` + +返回全部启用城市摘要,按省份编码和城市编码排序。 + +响应示例: + +```json +{ + "code": 200, + "message": "ok", + "data": [ + { + "cityCode": "110000", + "provinceCode": "110000", + "provinceName": "北京市", + "cityName": "北京市", + "policyVersion": "2026-09-20-v1", + "policyEffectiveDate": "2026-07-01", + "dataLevel": "CITY" + } + ] +} +``` + +## 3. 查询城市默认参数 + +`GET /salary-calculator/cities/{cityCode}` + +返回城市级或省级回退的当前默认参数。计算请求必须原样携带响应中的 `policyVersion` 作为 `expectedPolicyVersion`。 + +## 4. 计算并保存 + +`POST /salary-calculator/calculations` + +请求示例: + +```json +{ + "cityCode": "110000", + "grossMonthlySalary": 10000.00, + "monthIndex": 1, + "specialAdditionalDeduction": 0.00, + "expectedPolicyVersion": "2026-09-20-v1", + "idempotencyKey": "optional-client-key", + "overrides": { + "socialSecurityBase": 10000.00, + "housingFundBase": 10000.00, + "pensionRate": 0.08, + "medicalRate": 0.02, + "unemploymentRate": 0.005, + "housingFundRate": 0.12, + "seriousIllnessMonthlyFee": 0.00 + } +} +``` + +- `cityCode`、`grossMonthlySalary`、`expectedPolicyVersion` 必填。 +- `monthIndex` 默认 1,范围 1–12。 +- `specialAdditionalDeduction` 默认 0。 +- `idempotencyKey` 可选,最长 64 字符;同用户同键同规范化请求返回原历史,同键不同请求返回 `409/10604`。 +- 覆盖基数必须位于对应城市范围内;比例为 0 时允许基数为 0。 + +成功响应: + +```json +{ + "code": 200, + "message": "ok", + "data": { + "historyId": 123, + "createdAt": "2026-09-20T16:00:00", + "result": { + "cityCode": "110000", + "cityName": "北京市", + "dataLevel": "CITY", + "policyVersion": "2026-09-20-v1", + "policyEffectiveDate": "2026-07-01", + "taxRulesVersion": "CN_IIT_2019_V1", + "estimate": true, + "monthIndex": 1, + "grossMonthlySalary": 10000.00, + "socialSecurityBase": 10000.00, + "housingFundBase": 10000.00, + "pension": 800.00, + "medical": 200.00, + "unemployment": 50.00, + "housingFund": 1200.00, + "currentMonthTax": 82.50, + "netSalary": 7667.50 + } + } +} +``` + +## 5. 查询历史 + +`GET /salary-calculator/calculations?page=0&size=20` + +- 仅返回当前用户的记录。 +- 按创建时间倒序、历史 ID 倒序。 +- `size` 最大 100。 + +`GET /salary-calculator/calculations/{id}` + +返回输入、政策、覆盖项和结果三组完整 JSON 快照。访问他人历史统一返回 `404/10603`。 + +## 6. 删除历史 + +`DELETE /salary-calculator/calculations/{id}` + +物理删除本人历史;成功后再次查询返回 `404/10603`。删除后原幂等键可重新使用。 + +## 7. 错误码 + +| HTTP | code | 含义 | +|---|---|---| +| 400 | 10602 | 工资、比例、基数或分页参数非法 | +| 401 | 401 | 未登录或 Token 无效 | +| 403 | 2002 | 已登录但不是 `STUDENT` | +| 404 | 10601 | 城市不存在、停用或未配置 | +| 404 | 10603 | 历史不存在或不属于当前用户 | +| 409 | 10604 | 幂等键冲突 | +| 409 | 10605 | 城市政策版本已变化 | + +`10605` 响应会在 `data` 中返回最新 `policyVersion` 和 `policyEffectiveDate`,客户端应重新加载城市详情后再试算。 + +## 8. 计算口径 + +```text +社保基数 = 用户覆盖值,否则 min(max(税前月薪, 社保下限), 社保上限) +公积金基数 = 用户覆盖值,否则 min(max(税前月薪, 公积金下限), 公积金上限) + +累计收入 = 税前月薪 × monthIndex +累计基本减除费用 = 5000 × monthIndex +累计专项附加扣除 = 月度专项附加扣除 × monthIndex +累计应纳税所得额 = max(累计收入 - 累计个人社保 - 累计公积金 - 累计大病费 + - 累计基本减除费用 - 累计专项附加扣除, 0) +当月个税 = max(累计应纳税额 - 累计已预扣税额, 0) +到手工资 = max(税前月薪 - 个人养老 - 个人医疗 - 个人失业 - 个人公积金 + - 大病固定费 - 当月个税, 0) +``` diff --git a/docs/skill-cloud-api.md b/docs/skill-cloud-api.md new file mode 100644 index 0000000..476de9d --- /dev/null +++ b/docs/skill-cloud-api.md @@ -0,0 +1,275 @@ +# 行业技能词云、个人匹配度与学习路径 API + +> 版本:N03 设计锁定版 +> 状态:已编码实现 +> 适用范围:学生端只读接口。本文描述 `GET /api/v1/skills/word-cloud`、`GET /api/v1/skills/match`、`GET /api/v1/skills/learning-path`。 +> Base URL:`http://127.0.0.1:8080/api/v1`(生产环境替换域名,路径不变)。 + +## 1. 权限与共同参数 + +三个接口均: + +- 仅允许 `STUDENT` 访问。 +- 必须携带 `Authorization: Bearer `,身份只从 JWT/SecurityContext 获取,不接受 `userId`、`studentId` 等身份注入参数。 +- 成功、`400` 和 `403` 响应使用统一 `ApiResponse`;匿名或无效 JWT 的 `401` 由全局 Security 入口直接返回裸 JSON,不包含 `data` 和 `requestId`。 +- 只读,不写入学习进度、学生资料或词库数据。 + +查询参数: + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `industryId` | long | 是 | 词库行业 ID,必须为正整数,且必须是真实存在的行业。 | +| `occupationId` | long | 是 | 词库职位 ID,必须为正整数,且必须属于该行业。 | +| `categoryId` | long | 否 | 技能分类 ID,必须为正整数;省略时使用该职位下全部技能分类。传入时必须属于目标职位。 | + +任一必填参数缺失、非正整数,或行业—职位—分类关系非法时返回 `400`。如果未登录返回 `401`,非 STUDENT 用户返回 `403`。 + +## 2. 通用响应与排序 + +三个接口的 `data` 都使用 camelCase 字段。词云和 TOP10 使用同一份服务端排序结果: + +1. `heat` 从高到低;`heat` 为 `null` 的技能排在所有非空热度之后。 +2. 热度相同时按 `nameZh` 升序。 +3. 其余情况按 `skillId` 升序,保证重复请求顺序稳定。 +4. `rank` 从 1 连续递增。 + +`heat` 来自 O*NET Importance。无可靠 O*NET Importance 映射的 ESCO 技能仍返回,但 `heat` 为 `null`。TOP10 不在后端单独存储或单独排序;前端直接取词云 `data.items` 的前 `min(10, data.total)` 项。 + +技能身份来源: + +- `sourceType`:`ESCO` 或 `O*NET`,表示技能条目的主来源。 +- `sourceCode`:可追溯来源标识。ESCO 为技能 URI;O*NET 补充技能为 `SOC|Workplace Example|Element ID`。 +- `heatSourceCode`:热度来源 SOC;没有 O*NET 热度时为 `null`。 +- `heatElementId`:O*NET Importance 的来源 Element ID;没有时为 `null`。 +- `heatProxy`:该条热度是否为代理映射。代理值不改变排序规则,但前端可据此展示来源说明。 + +## 3. 查询词云 + +```http +GET /api/v1/skills/word-cloud?industryId=1&occupationId=2&categoryId=101 +Authorization: Bearer +``` + +`categoryId` 可省略: + +```http +GET /api/v1/skills/word-cloud?industryId=1&occupationId=2 +``` + +成功响应为 `ApiResponse`。`SkillCloudResponse` 返回筛选节点、总数和当前范围内的完整技能列表: + +```json +{ + "code": 200, + "message": "ok", + "data": { + "industry": { "id": 1, "code": "INTERNET", "name": "互联网" }, + "occupation": { "id": 1, "code": "FRONTEND_DEVELOPER", "name": "前端开发工程师" }, + "category": { "id": 103, "code": "FRONTEND_FRAMEWORK", "name": "前端框架" }, + "total": 1, + "items": [ + { + "rank": 1, + "skillId": 26, + "skillCode": "REACT", + "nameZh": "React", + "nameEn": "React", + "sourceType": "O*NET", + "sourceCode": "15-1254.00|React|2.E.7.h", + "categoryId": 103, + "categoryName": "前端框架", + "heat": 4.38, + "heatSourceCode": "15-1254.00", + "heatElementId": "2.E.7.h", + "heatProxy": true + } + ] + }, + "requestId": "req-123", + "timestamp": "2026-09-15T10:00:00Z" +} +``` + +`category` 在未传 `categoryId` 时为 `null`。合法范围但暂时没有技能时返回成功、`total=0`、`items=[]`。前端根据 `items` 渲染词云,并截取前十条作为 TOP10。 + +## 4. 查询个人技能匹配度 + +```http +GET /api/v1/skills/match?industryId=1&occupationId=2&categoryId=101 +Authorization: Bearer +``` + +匹配集合严格取当前筛选结果的 TOP10。系统从当前 JWT 学生的 `student_profiles.skills_json` 读取技能,使用标准技能名、英文名、技能代码和别名归一化匹配。大小为 `total` 的筛选范围使用 `min(10, total)` 作为分母: + +```text +matchScore = round(matchedCount * 100 / min(10, total)) +``` + +- `total=0` 时 `matchScore=0`,命中列表和缺失列表为空。 +- 范围少于 10 项时按实际技能数计算。 +- 重复技能或指向同一标准技能的多个别名只计一次。 +- 示例:范围 15 条、命中 5 条,`matchScore=33`。 +- 原始 `skills_json`、手机号、邮箱和简历内容不会出现在响应中。 + +成功响应为 `ApiResponse`: + +```json +{ + "code": 200, + "message": "ok", + "data": { + "matchScore": 50, + "matchedCount": 1, + "totalCount": 2, + "matchedSkills": [ + { + "rank": 1, + "skillId": 26, + "skillCode": "REACT", + "nameZh": "React", + "nameEn": "React", + "sourceType": "O*NET", + "sourceCode": "15-1254.00|React|2.E.7.h", + "categoryId": 103, + "categoryName": "前端框架", + "heat": 4.38, + "heatSourceCode": "15-1254.00", + "heatElementId": "2.E.7.h", + "heatProxy": true + } + ], + "missingSkills": [ + { + "rank": 2, + "skillId": 31, + "skillCode": "DOCKER", + "nameZh": "Docker", + "nameEn": "Docker", + "sourceType": "O*NET", + "sourceCode": "15-1299.08|Docker|2.E.7.h", + "categoryId": 105, + "categoryName": "工程化工具", + "heat": 4.12, + "heatSourceCode": "15-1299.08", + "heatElementId": "2.E.7.h", + "heatProxy": true + } + ] + }, + "requestId": "req-456", + "timestamp": "2026-09-15T10:01:00Z" +} +``` + +`matchedSkills` 与 `missingSkills` 的并集等于当前 TOP10,两个列表各自保持词云排序;`totalCount` 是实际匹配集合大小。 + +## 5. 生成学习路径 + +```http +GET /api/v1/skills/learning-path?industryId=1&occupationId=2&categoryId=101 +Authorization: Bearer +``` + +学习路径只消费个人匹配结果中的 `missingSkills`: + +- 只包含当前 TOP10 中未命中的技能,不包含已命中技能或 TOP10 之外的技能。 +- 顺序与 `missingSkills` 完全一致,`priority` 从 1 连续递增。 +- 每项只返回技能 ID、名称、分类、优先级和简化学习建议。 +- 后端不调用 AI、课程平台或外部知识库;不会返回课程链接、讲师、价格、学习时长或完成度。 +- 系统不持久化学习进度或完成状态。 +- 全部命中或合法范围没有技能时返回 `total=0`、`items=[]`。 + +建议模板按技能分类确定性生成: + +| 模板类别 | 建议方向 | +|---|---| +| 编程语言 | 基础语法、常见数据结构、至少一个编码练习。 | +| 框架 | 核心概念与生命周期、小型项目集成实践。 | +| 工具 | 安装与基础配置、结合工程完成实际操作。 | +| 通用 | 核心概念和常见应用场景、简短实践巩固。 | + +```json +{ + "code": 200, + "message": "ok", + "data": { + "total": 1, + "items": [ + { + "priority": 1, + "skillId": 26, + "skillName": "React", + "categoryId": 103, + "categoryName": "前端框架", + "suggestion": "先理解 React 的核心概念与生命周期,再通过一个小型项目练习集成。" + } + ] + }, + "requestId": "req-789", + "timestamp": "2026-09-15T10:02:00Z" +} +``` + +## 6. 错误响应示例 + +`400` 和 `403` 使用统一 `ApiResponse`;`401` 由全局 Security 入口返回裸 JSON。以下示例与运行时契约一致。 + +```json +{ + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-400", + "timestamp": "2026-09-15T10:03:00Z" +} +``` + +| HTTP | 场景 | 典型 `code` | +|---|---|---| +| `400` | `industryId`/`occupationId` 缺失、非正整数,或节点关系非法 | `1001` | +| `401` | 未携带有效 JWT 或令牌无效/过期 | `401` | +| `403` | 已登录但不是 STUDENT | `2002` | + +示例 401: + +```json +{ + "code": 401, + "message": "not authenticated", + "timestamp": "" +} +``` + +`401` 响应不包含 `data` 和 `requestId`。 + +示例 403: + +```json +{ + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-403", + "timestamp": "2026-09-15T10:05:00Z" +} +``` + +## 7. 字段参考 + +### SkillCloudNode + +`id`、`code`、`name`。`category` 节点在未指定分类时为 `null`。 + +### SkillCloudItem + +`rank`、`skillId`、`skillCode`、`nameZh`、`nameEn`、`sourceType`、`sourceCode`、`categoryId`、`categoryName`、`heat`、`heatSourceCode`、`heatElementId`、`heatProxy`。 + +### SkillMatchResponse + +`matchScore`、`matchedCount`、`totalCount`、`matchedSkills`、`missingSkills`。 + +### SkillLearningPathItem / SkillLearningPathResponse + +`SkillLearningPathItem`:`priority`、`skillId`、`skillName`、`categoryId`、`categoryName`、`suggestion`。 + +`SkillLearningPathResponse`:`total`、`items`。 diff --git a/docs/skill-taxonomy-sources.md b/docs/skill-taxonomy-sources.md new file mode 100644 index 0000000..09c30f2 --- /dev/null +++ b/docs/skill-taxonomy-sources.md @@ -0,0 +1,79 @@ +# 行业技能词库来源与覆盖说明 + +本说明是仓库面向实现和评审的技能词库来源基线,内容与 `changes/n03-skill-cloud/data/source-manifest.md` 保持同一事实集合。 + +## 1. 固定来源 + +| 来源 | 固定版本 | 用途 | 许可/复用条件 | 官方地址与归属 | +| --- | --- | --- | --- | --- | +| ESCO | ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0) | 提供行业/职位映射所需的职业—技能—技能分类主关系、稳定来源 ID 和层级结构 | 依据 Commission Decision 2011/833/EU,可免费下载、使用、复制和复用。文档和产品界面须保留官方署名语句:`This service uses the ESCO classification of the European Commission.` | 数据集下载页:https://esco.ec.europa.eu/en/use-esco/download
复用条件说明:https://esco.ec.europa.eu/en/about-esco/faq | +| O*NET | O*NET 31.0 Database | 为 ESCO 未收录的具体技术提供 `software_skills` 补充来源,并通过职业/工作活动 Importance 提供首版词云热度 `heat` | License 为 CC BY 4.0,归属与许可说明见 https://creativecommons.org/licenses/by/4.0/ | 官方数据库页:https://www.onetcenter.org/database.html | + +运行时不得访问 ESCO、O*NET 或任何在线词库服务。所有首版数据必须随 Flyway 迁移进入本地数据库,并保留来源版本与归属信息。 + +## 2. 首版覆盖边界 + +首版只覆盖互联网、金融、教育三类行业及下列 10 个已列代表性职位。该矩阵定义导入上限和最小覆盖范围,不要求在全量上游数据中导入未列出的职位。 + +| 行业 | 代表性职位 | 首批技能分类范围 | +| --- | --- | --- | +| 互联网 | 前端开发工程师 | 编程语言、Web 基础、前端框架、工程化工具、浏览器与调试、测试、版本控制与协作 | +| 互联网 | 后端开发工程师 | 编程语言、后端框架与平台、数据库、缓存与消息、云/DevOps、测试、工程化工具、安全 | +| 互联网 | 测试工程师 | 编程语言、测试设计、自动化测试框架、接口与数据库测试、性能测试、CI/CD 与工程化工具 | +| 互联网 | DevOps 工程师 | 编程语言与脚本、云平台、容器与编排、CI/CD、配置管理与 IaC、可观测性、安全、数据库 | +| 金融 | 金融数据分析师 | 编程语言、数据分析与可视化、数据库与数据仓库、统计与建模、金融计算框架/平台、金融领域知识、工程化工具 | +| 金融 | 风控建模工程师 | 编程语言、统计与建模、风险模型、建模框架/平台、数据处理与数据库、模型验证、部署与工程化工具 | +| 金融 | 量化开发工程师 | 编程语言、量化建模框架/平台、市场与金融数据、数据库、低延迟与高性能工程、测试、云/DevOps | +| 教育 | 教育产品经理 | 教育领域知识、产品分析方法、需求与流程、数据分析、教育平台与工具、协作与工程化工具 | +| 教育 | 教学设计师 | 教学设计、课程与学习平台、数字化内容工具、评估设计、学习数据分析、无障碍与规范 | +| 教育 | 教育数据分析师 | 编程语言、数据分析与可视化、数据库与 SQL、统计与建模、教育领域知识、报表与工程化工具 | +计数说明:任务给出的逐项职位清单为互联网 4 个、金融 3 个、教育 3 个,共 10 个。验收文字中的“11 个职位”与该枚举不一致;本清单严格覆盖逐项列出的 10 个职位,不自行新增未批准职位。 + +矩阵中的分类是每个职位首批应覆盖的能力域;T3 只导入这些分类下已确定稳定来源 ID、标准技能和中文标准名的技能,不在此文档中展开具体 `INSERT` 行。 + +## 3. 导入规则 + +1. **职责分工**:T2 只建立七张词库表、外键、唯一约束和查询索引;T3 只导入上述三类行业、10 个已列代表性职位及其关联技能分类、技能、别名和 O*NET Importance。 +2. **代表性子集与混合来源**:不导入 ESCO/O*NET 全量数据;ESCO 是职位、通用技能和分类主表;ESCO 无可靠节点的具体技术使用 O*NET `software_skills` 补充;不把平台自由文本岗位或行业直接当作标准词库节点。 +3. **来源 ID 不变**:ESCO/O*NET 来源 ID 原样保存在稳定来源字段中;项目不得重编号、覆盖或伪造上游 ID。 +4. **中文标准名必填**:每个入选技能必须有非空 `name_zh` 中文标准名;中文名称由项目维护,但不替代或修改上游来源 ID。 +5. **热度来源**:每条非空 Importance 必须保存 O*NET SOC、Element ID 和代理标记;技能无法映射 O*NET Importance 时必须保留,写入 `importance=null`;查询时映射为 `heat=null`,并排在有热度技能之后。 +6. **项目映射责任**:项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。行业/职位映射、细粒度分类和跨源技能对应关系均作为项目适配层记录。 +7. **别名归一化**:生成 `normalized_alias` 时依次执行 Unicode NFKC、首尾空白移除、大小写折叠、连字符转空格、连续空白折叠为单空格、句点移除;`+` 和 `#` 必须保留,不能删除或替换。 +8. **来源可追溯**:来源清单写入 `skill_source_versions`,至少记录来源名称、固定版本、许可、归属 URL 和导入时间;O*NET 补充技术的 `source_code` 使用真实 `SOC|Workplace Example|Element ID` 组合,职位技能关系记录 O*NET SOC、Element ID、代理标记及关联的来源版本。 +9. **迁移不可改写**:T2/T3 计划使用 `V47__skill_cloud_taxonomy.sql` 和 `V48__skill_cloud_seed.sql`。执行前若编号已被占用,必须回退规划并刷新编号;后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或任何已执行迁移。 +10. **运行时离线**:接口运行时不得调用外部词库、AI、课程平台或同步服务;排名只读取本地 Flyway 数据。 + +## 4. 来源版本、许可与归属 URL + +| 数据落点 | 来源名称 | 版本字段固定值 | 许可字段固定值 | 归属 URL | +| --- | --- | --- | --- | --- | +| 职业、技能、技能分类及关系 | ESCO | `1.2.1 (Release v5.7.0, Last update 10/12/2025)` | `Commission Decision 2011/833/EU` | https://esco.ec.europa.eu/en/use-esco/download | +| 职位技能 Importance | O*NET | `31.0 Database` | `CC BY 4.0` | https://www.onetcenter.org/database.html | + +ESCO 产品署名必须使用:`This service uses the ESCO classification of the European Commission.` + +## 5. 映射责任边界 + +| 事项 | 上游来源责任 | 项目责任 | +| --- | --- | --- | +| ESCO 职业、技能、分类层级和来源 ID | 固定且可追溯 | 原样保存,不修改 ID | +| O*NET Importance 数值 | 固定且可追溯 | 原样保存;无映射时写 `null`,不造权重 | +| 中文名称和别名 | 不保证完整中文覆盖 | 补齐中文标准名、别名及归一化值 | +| 细粒度技能分类 | 不保证符合本项目展示粒度 | 在职位下维护分类和展示名称 | +| 行业/职位映射 | 不保证覆盖三类项目行业和中文职位名 | 维护行业—职位—分类—技能映射 | +| 跨源技能与重要性关联 | 不提供项目所需的稳定本地关系 | 维护 ESCO 技能到 O*NET `software_skills` 的补充映射,以及每条 Importance 的 SOC/Element ID/代理标记 | + +## 6. 验收检查项 + +- AC-1:`changes/n03-skill-cloud/data/source-manifest.md` 与 `docs/skill-taxonomy-sources.md` 均存在且非空,来源版本、许可、归属 URL、覆盖矩阵和导入规则一致。 +- AC-2:文档明确覆盖互联网、金融、教育三类行业及全部 10 个已列代表性职位,并为每个职位给出首批技能分类范围。 +- AC-3:文档明确固定 ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0)和 O*NET 31.0 Database。 +- AC-4:文档明确 ESCO 依据 Commission Decision 2011/833/EU 可免费下载、使用、复制和复用,并原样保留指定署名语句。 +- AC-5:文档明确 O*NET License 为 CC BY 4.0,并记录官方数据库页与许可 URL。 +- AC-6:文档明确项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。 +- AC-7:文档明确 T2 建结构、T3 导数据,只导入代表性职位和关联技能;技能中文标准名必填。 +- AC-8:文档明确 O*NET 补充技术、每条 Importance 的 SOC/Element ID/代理标记,以及无映射时保留技能并写入 `importance=null`、查询侧映射为 `heat=null`。 +- AC-9:文档明确别名归一化遵循 NFKC、首尾空白、大小写、连字符、连续空白、句点规则,且保留 `+`、`#`。 +- AC-10:文档明确后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或已执行迁移。 +- AC-11:来源、覆盖、导入与责任边界中不得出现未决占位或延期确认语句。 diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 1917bbf..23b251b 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']) == 183, f"Expected 183 paths, got {len(d['paths'])}" +assert len(d['paths']) == 196, f"Expected 196 paths (183 baseline + 13 module paths), got {len(d['paths'])}" paths = d['paths'] @@ -706,7 +706,7 @@ print(' S03 company resume download contract validated; candidate DTOs remain r # 统计 operation 总数并校验 V43 + N07 + S02 + S03 + Phase A 计数 220 operations = sum(len(methods) for methods in d['paths'].values()) print('operations:', operations) -assert operations == 221, f"Expected 221 operations, got {operations}" +assert operations == 239, f"Expected 239 operations (221 baseline + 18 module operations), got {operations}" # 统一账号身份选择:只允许两种业务身份,保持旧登录字段引用。 _select = paths['/auth/identity/select']['post'] @@ -718,6 +718,79 @@ print(" Offer advance endpoints all present") print(" 26 operations all present") print(" Account deletion endpoints present (3 operations)") +schemas = {name.removeprefix('Lxq'): schema for name, schema in + d['components']['schemas'].items() if name.startswith('Lxq')} +onboarding_success_refs = { + ('/onboarding/companies/{companyId}/checklist', 'get'): 'ApiResponseEffectiveChecklistResponse', + ('/onboarding/companies/{companyId}/checklist/items/{itemId}/completion', 'put'): 'ApiResponseProgressUpdateResponse', + ('/onboarding/companies/{companyId}/knowledge', 'get'): 'ApiResponseCompanyKnowledgeOverviewResponse', + ('/onboarding/company/checklist-items', 'get'): 'ApiResponseListCompanyChecklistItemView', + ('/onboarding/company/checklist-items', 'post'): 'ApiResponseCompanyChecklistItemView', + ('/onboarding/company/checklist-items/{itemId}', 'put'): 'ApiResponseCompanyChecklistItemView', + ('/onboarding/company/checklist-items/{itemId}', 'delete'): 'ApiResponseCompanyChecklistItemView', + ('/onboarding/company/knowledge', 'get'): 'ApiResponseCompanyKnowledgeView', + ('/onboarding/company/knowledge', 'put'): 'ApiResponseCompanyKnowledgeView', +} +for (path, method), schema_name in onboarding_success_refs.items(): + response = paths[path][method]['responses']['200'] + ref = response['content']['*/*']['schema']['$ref'] + assert ref == f'#/components/schemas/Lxq{schema_name}', f'Wrong success schema for {method.upper()} {path}: {ref}' + for status_code in ('401', '403'): + assert status_code in paths[path][method]['responses'], f'Missing {status_code} for {method.upper()} {path}' + unauthorized = paths[path][method]['responses']['401'] + assert unauthorized['content']['application/json']['schema']['$ref'] == '#/components/schemas/LxqUnauthorizedResponse' + forbidden = paths[path][method]['responses']['403'] + assert forbidden['content']['application/json']['schema']['$ref'] == '#/components/schemas/LxqApiResponse' + assert forbidden['content']['application/json']['example']['code'] == 2002 + +onboarding_schema_fields = { + 'CompanyChecklistItemCreateRequest': {'title', 'description', 'category', 'sortOrder', 'required', 'idempotencyKey'}, + 'CompanyChecklistItemUpdateRequest': {'title', 'description', 'category', 'sortOrder', 'required', 'active', 'version'}, + 'CompanyChecklistItemView': {'itemId', 'source', 'itemKey', 'title', 'description', 'category', 'required', 'sortOrder', 'active', 'version'}, + 'EffectiveChecklistResponse': {'companyId', 'companyName', 'customized', 'totalItems', 'completedItems', 'completionRate', 'items'}, + 'ChecklistItemView': {'itemId', 'source', 'itemKey', 'title', 'description', 'category', 'required', 'sortOrder', 'active', 'completed', 'completedAt', 'progressVersion'}, + 'ProgressUpdateRequest': {'completed', 'version'}, + 'ProgressUpdateResponse': {'itemId', 'completed', 'completedAt', 'progressVersion', 'totalItems', 'completedItems', 'completionRate'}, + 'CompanyKnowledgeUpdateRequest': {'culture', 'workPolicies', 'onboardingGuide', 'version'}, + 'CompanyKnowledgeView': {'configured', 'culture', 'workPolicies', 'onboardingGuide', 'updatedAt', 'version'}, + 'CompanyKnowledgeOverviewResponse': {'company', 'knowledgeState', 'knowledge', 'reviewSummary'}, + 'CompanyView': {'id', 'name', 'logoUrl', 'description', 'industry', 'website', 'address', 'verified'}, + 'KnowledgeView': {'culture', 'workPolicies', 'onboardingGuide', 'updatedAt', 'version'}, + 'ReviewSummaryView': {'state', 'totalReviews', 'averageRating', 'ratingDistribution', 'topTags', 'dimensionSummary'}, + 'DimensionSummaryView': {'averageScore', 'sampleCount', 'dataState'}, +} +for schema_name, expected_fields in onboarding_schema_fields.items(): + schema = schemas.get(schema_name) + assert schema is not None, f'Missing onboarding schema: {schema_name}' + assert expected_fields <= set(schema.get('properties', {})), ( + f'Onboarding schema fields missing: {schema_name}' + ) + +create_request = schemas['CompanyChecklistItemCreateRequest']['properties'] +assert create_request['sortOrder'].get('default') == 1000, ( + 'CompanyChecklistItemCreateRequest.sortOrder default must be 1000' +) +assert create_request['required'].get('default') is False, ( + 'CompanyChecklistItemCreateRequest.required default must be false' +) +knowledge_success_example = paths['/onboarding/company/knowledge']['put']['responses']['200']['content']['*/*']['example'] +assert knowledge_success_example['data'].get('version') == 2, ( + 'PUT /onboarding/company/knowledge 200 example data.version must be 2' +) + +onboarding_error_paths = { + 10501: ('/onboarding/company/checklist-items/{itemId}', 'put', '404'), + 10502: ('/onboarding/company/checklist-items/{itemId}', 'put', '409'), + 10503: ('/onboarding/company/checklist-items', 'post', '409'), + 10504: ('/onboarding/company/checklist-items', 'post', '409'), + 10505: ('/onboarding/companies/{companyId}/checklist/items/{itemId}/completion', 'put', '409'), + 10506: ('/onboarding/company/knowledge', 'put', '409'), +} +for error_code, (path, method, status_code) in onboarding_error_paths.items(): + response_text = json.dumps(paths[path][method]['responses'][status_code], ensure_ascii=False) + assert str(error_code) in response_text, f'Missing onboarding error code {error_code} in {method.upper()} {path}' +print(' N04 onboarding endpoints and schemas present (9 operations / 6 paths)') + data = json.dumps(d) assert '"" : "#/components/schemas/' not in data, "Found empty ref key" print("No empty ref keys found") 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 a6ee177..1e466c6 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 @@ -244,7 +244,18 @@ public enum ErrorCode { IDENTITY_SELECTION_REQUIRED(10401, "identity selection is required"), IDENTITY_SELECTION_CONFLICT(10402, "identity selection conflict"), - IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE(10403, "identity selection temporarily unavailable"); + IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE(10403, "identity selection temporarily unavailable"), + ONBOARDING_CHECKLIST_ITEM_NOT_FOUND(10501, "onboarding checklist item not found"), + ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE(10502, "onboarding checklist item was modified concurrently"), + ONBOARDING_ITEM_LIMIT_EXCEEDED(10503, "onboarding checklist item limit exceeded"), + ONBOARDING_IDEMPOTENCY_CONFLICT(10504, "idempotency key conflict: request body differs"), + ONBOARDING_PROGRESS_CONCURRENT_UPDATE(10505, "onboarding progress was modified concurrently"), + ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE(10506, "company knowledge was modified concurrently"), + SALARY_CITY_NOT_FOUND(10601, "salary city not found or disabled"), + SALARY_INVALID_PARAMETER(10602, "invalid salary calculation parameter"), + SALARY_HISTORY_NOT_FOUND(10603, "salary calculation history not found"), + SALARY_IDEMPOTENCY_CONFLICT(10604, "salary idempotency key conflict: request body differs"), + SALARY_POLICY_VERSION_CONFLICT(10605, "salary city policy version conflict"); private final int code; private final String message; 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 4443e6c..c5d4163 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 @@ -118,6 +118,9 @@ public final class ErrorHttpStatusRegistry { ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION, ErrorCode.OFFER_COMPARE_NOT_OWNER) .put(HttpStatus.NOT_FOUND, + ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND, + ErrorCode.SALARY_CITY_NOT_FOUND, + ErrorCode.SALARY_HISTORY_NOT_FOUND, ErrorCode.ABILITY_ATTEMPT_NOT_FOUND, ErrorCode.ABILITY_RESULT_NOT_FOUND, ErrorCode.ABILITY_TYPE_NOT_FOUND, @@ -146,6 +149,13 @@ public final class ErrorHttpStatusRegistry { ErrorCode.USER_NOT_FOUND, ErrorCode.VERIFICATION_NOT_FOUND) .put(HttpStatus.CONFLICT, + ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE, + ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED, + ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT, + ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE, + ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE, + ErrorCode.SALARY_IDEMPOTENCY_CONFLICT, + ErrorCode.SALARY_POLICY_VERSION_CONFLICT, ErrorCode.PASSWORD_STATE_CONFLICT, ErrorCode.IDENTITY_SELECTION_CONFLICT, ErrorCode.ABILITY_ATTEMPT_INVALID_STATE, diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index 573fb2e..06b3a10 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.mydreamjob.common.exception; import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.salarycalculator.SalaryPolicyVersionConflictException; import com.mydreamjob.common.logging.RequestIdAccessor; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolationException; @@ -39,6 +40,20 @@ public class GlobalExceptionHandler { private final RequestIdAccessor requestIdAccessor; private final ErrorHttpStatusRegistry errorHttpStatusRegistry; + public record SalaryPolicyConflictDetails(String latestPolicyVersion, + java.time.LocalDate policyEffectiveDate) {} + + @ExceptionHandler(SalaryPolicyVersionConflictException.class) + public ResponseEntity> handleSalaryPolicyConflict( + SalaryPolicyVersionConflictException ex, HttpServletRequest request) { + ErrorCode code = ErrorCode.SALARY_POLICY_VERSION_CONFLICT; + ApiResponse body = ApiResponse.error( + code.getCode(), code.getMessage(), getRequestId(request)); + body.setData(new SalaryPolicyConflictDetails( + ex.getLatestPolicyVersion(), ex.getLatestPolicyEffectiveDate())); + return ResponseEntity.status(errorHttpStatusRegistry.resolve(code.getCode())).body(body); + } + @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity> handleConstraintViolation( ConstraintViolationException ex, HttpServletRequest request) { 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 3193e1c..805771f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java @@ -5,6 +5,7 @@ import com.mydreamjob.auth.AuthSessionService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.dataexport.PersonalDataExportCleanupService; +import com.mydreamjob.salarycalculator.SalaryHistoryCleanupService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.orm.ObjectOptimisticLockingFailureException; @@ -35,6 +36,7 @@ public class AccountDeletionCompletionWorker { private final AuditService auditService; private final AccountDeletionPhoneHasher phoneHasher; private final PersonalDataExportCleanupService dataExportCleanupService; + private final SalaryHistoryCleanupService salaryHistoryCleanupService; private final AuthSessionService authSessionService; private final Clock clock; @@ -68,6 +70,7 @@ public class AccountDeletionCompletionWorker { rec.getUserId(), AuthSessionService.REASON_ACCOUNT_DELETED, capturedNow); // 注销完成:清理该用户的个人导出文件并作废未完成任务 dataExportCleanupService.purgeForDeletedUser(rec.getUserId()); + salaryHistoryCleanupService.purgeForDeletedUser(rec.getUserId()); // 完成 + 脱敏审计各写一次 auditService.log(userId, "ACCOUNT_DELETION_COMPLETED", "ACCOUNT_DELETION", rec.getId(), "account deletion completed; userId=" + userId @@ -120,4 +123,4 @@ public class AccountDeletionCompletionWorker { u.setActive(false); userRepository.saveAndFlush(u); } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledge.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledge.java new file mode 100644 index 0000000..1b98711 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledge.java @@ -0,0 +1,63 @@ +package com.mydreamjob.onboarding; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table( + name = "company_knowledge", + uniqueConstraints = @UniqueConstraint( + name = "uk_company_knowledge_company", + columnNames = "company_id")) +@EntityListeners(AuditingEntityListener.class) +public class CompanyKnowledge { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "company_id", nullable = false) + private Long companyId; + + @Column(length = 2000) + private String culture; + + @Column(name = "work_policies", length = 4000) + private String workPolicies; + + @Column(name = "onboarding_guide", length = 4000) + private String onboardingGuide; + + @Version + @Column(nullable = false) + @Builder.Default + private Long version = 0L; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeController.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeController.java new file mode 100644 index 0000000..1e40d95 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeController.java @@ -0,0 +1,74 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeView; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "公司知识介绍", description = "企业维护知识内容,学生读取公司知识聚合") +@RestController +@RequestMapping("/onboarding") +@RequiredArgsConstructor +public class CompanyKnowledgeController { + + private final CompanyKnowledgeService companyKnowledgeService; + + @Operation(summary = "读取本企业知识配置") + @GetMapping("/company/knowledge") + public ResponseEntity> getCompanyKnowledge( + HttpServletRequest request) { + Long userId = resolveUserId(); + CompanyKnowledgeView data = companyKnowledgeService.getCompanyKnowledge(userId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "创建或更新本企业知识配置") + @PutMapping("/company/knowledge") + public ResponseEntity> upsertCompanyKnowledge( + @Valid @RequestBody CompanyKnowledgeUpdateRequest body, + HttpServletRequest request) { + Long userId = resolveUserId(); + CompanyKnowledgeView data = + companyKnowledgeService.upsertCompanyKnowledge(userId, body); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "读取公司知识介绍") + @GetMapping("/companies/{companyId}/knowledge") + public ResponseEntity> getCompanyKnowledgeOverview( + @PathVariable Long companyId, + HttpServletRequest request) { + Long userId = resolveUserId(); + CompanyKnowledgeOverviewResponse data = + companyKnowledgeService.getCompanyKnowledgeOverview(userId, companyId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + private Long resolveUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + return Long.valueOf(authentication.getName()); + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeRepository.java new file mode 100644 index 0000000..17bc85f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeRepository.java @@ -0,0 +1,12 @@ +package com.mydreamjob.onboarding; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface CompanyKnowledgeRepository extends JpaRepository { + + Optional findByCompanyId(Long companyId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java new file mode 100644 index 0000000..78f28de --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java @@ -0,0 +1,412 @@ +package com.mydreamjob.onboarding; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import jakarta.persistence.PersistenceContext; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeView; +import com.mydreamjob.onboarding.dto.ReviewSummaryView; +import com.mydreamjob.review.JobReviewDimensionScoreRepository; +import com.mydreamjob.review.JobReviewRepository; +import com.mydreamjob.review.ReviewDimension; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class CompanyKnowledgeService { + + private static final String CONFIGURED = "CONFIGURED"; + private static final String BASIC_ONLY = "BASIC_ONLY"; + private static final String EMPTY = "EMPTY"; + private static final String INSUFFICIENT = "INSUFFICIENT"; + private static final String SUFFICIENT = "SUFFICIENT"; + + private static final int MINIMUM_PUBLIC_REVIEWS = 3; + private static final int MAX_TOP_TAGS = 10; + private static final int MAX_CULTURE_LENGTH = 2000; + private static final int MAX_WORK_POLICIES_LENGTH = 4000; + private static final int MAX_ONBOARDING_GUIDE_LENGTH = 4000; + private static final Pattern HTML_TAG_PATTERN = + Pattern.compile("<\\s*/?\\s*[A-Za-z!][^>]*>"); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final UserRepository userRepository; + private final CompanyRepository companyRepository; + private final CompanyKnowledgeRepository companyKnowledgeRepository; + private final JobReviewRepository jobReviewRepository; + private final JobReviewDimensionScoreRepository dimensionScoreRepository; + + @PersistenceContext + private EntityManager entityManager; + + @Transactional(readOnly = true) + public CompanyKnowledgeView getCompanyKnowledge(Long userId) { + Company company = requireOwnedCompany(userId); + return companyKnowledgeRepository.findByCompanyId(company.getId()) + .map(this::toConfiguredView) + .orElseGet(() -> CompanyKnowledgeView.builder() + .configured(false) + .version(0L) + .build()); + } + + @Transactional + public CompanyKnowledgeView upsertCompanyKnowledge( + Long userId, + CompanyKnowledgeUpdateRequest request) { + Company company = requireOwnedCompany(userId); + KnowledgeContent content = validateContent(request); + lockCompany(company); + CompanyKnowledge existing = companyKnowledgeRepository.findByCompanyId(company.getId()) + .orElse(null); + + if (existing == null) { + CompanyKnowledge created = CompanyKnowledge.builder() + .companyId(company.getId()) + .culture(content.culture()) + .workPolicies(content.workPolicies()) + .onboardingGuide(content.onboardingGuide()) + .build(); + return toConfiguredView(saveKnowledge(created)); + } + + if (request.getVersion() == null && matchesContent(existing, content)) { + return toConfiguredView(existing); + } + if (request.getVersion() == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (!Objects.equals(request.getVersion(), existing.getVersion())) { + throw new BusinessException(ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE); + } + + existing.setCulture(content.culture()); + existing.setWorkPolicies(content.workPolicies()); + existing.setOnboardingGuide(content.onboardingGuide()); + return toConfiguredView(saveKnowledge(existing)); + } + + @Transactional(readOnly = true) + public CompanyKnowledgeOverviewResponse getCompanyKnowledgeOverview( + Long userId, + Long companyId) { + assertStudentRole(userId); + Company company = companyRepository.findById(companyId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + CompanyKnowledge knowledge = companyKnowledgeRepository.findByCompanyId(companyId) + .orElse(null); + + return CompanyKnowledgeOverviewResponse.builder() + .company(toCompanyView(company)) + .knowledgeState(knowledge == null ? BASIC_ONLY : CONFIGURED) + .knowledge(knowledge == null ? null : toKnowledgeView(knowledge)) + .reviewSummary(buildReviewSummary(companyId)) + .build(); + } + + private CompanyKnowledge saveKnowledge(CompanyKnowledge knowledge) { + try { + return companyKnowledgeRepository.saveAndFlush(knowledge); + } catch (ObjectOptimisticLockingFailureException | DataIntegrityViolationException ex) { + throw new BusinessException( + ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE, ex); + } + } + + private boolean matchesContent(CompanyKnowledge knowledge, KnowledgeContent content) { + return Objects.equals(knowledge.getCulture(), content.culture()) + && Objects.equals(knowledge.getWorkPolicies(), content.workPolicies()) + && Objects.equals(knowledge.getOnboardingGuide(), content.onboardingGuide()); + } + + private void lockCompany(Company company) { + if (entityManager == null || company == null || company.getId() == null) { + return; + } + entityManager.lock(company, LockModeType.PESSIMISTIC_WRITE); + } + + private KnowledgeContent validateContent(CompanyKnowledgeUpdateRequest request) { + if (request == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (request.getVersion() != null && request.getVersion() < 0) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + String culture = normalizeText(request.getCulture(), MAX_CULTURE_LENGTH); + String workPolicies = normalizeText( + request.getWorkPolicies(), MAX_WORK_POLICIES_LENGTH); + String onboardingGuide = normalizeText( + request.getOnboardingGuide(), MAX_ONBOARDING_GUIDE_LENGTH); + + if (culture == null && workPolicies == null && onboardingGuide == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + return new KnowledgeContent(culture, workPolicies, onboardingGuide); + } + + private String normalizeText(String raw, int maxLength) { + if (!StringUtils.hasText(raw)) { + return null; + } + String value = raw.trim(); + if (value.length() > maxLength) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (HTML_TAG_PATTERN.matcher(value).find()) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + return value; + } + + private ReviewSummaryView buildReviewSummary(Long companyId) { + List countRows = + jobReviewRepository.countAndAveragePublishedRatingByCompanyId(companyId); + long totalReviews = extractCount(countRows); + + if (totalReviews == 0) { + return ReviewSummaryView.builder() + .state(EMPTY) + .totalReviews(0) + .build(); + } + if (totalReviews < MINIMUM_PUBLIC_REVIEWS) { + return ReviewSummaryView.builder() + .state(INSUFFICIENT) + .totalReviews(Math.toIntExact(totalReviews)) + .build(); + } + + return ReviewSummaryView.builder() + .state(SUFFICIENT) + .totalReviews(Math.toIntExact(totalReviews)) + .averageRating(extractAverageRating(countRows)) + .ratingDistribution(buildRatingDistribution(companyId)) + .topTags(buildTopTags(companyId)) + .dimensionSummary(buildDimensionSummary(companyId)) + .build(); + } + + private long extractCount(List rows) { + if (rows == null || rows.isEmpty() || rows.get(0) == null + || rows.get(0).length == 0 || !(rows.get(0)[0] instanceof Number count)) { + return 0L; + } + return count.longValue(); + } + + private Double extractAverageRating(List rows) { + if (rows == null || rows.isEmpty() || rows.get(0) == null + || rows.get(0).length < 2 || !(rows.get(0)[1] instanceof Number average)) { + return 0.0; + } + return BigDecimal.valueOf(average.doubleValue()) + .setScale(1, RoundingMode.HALF_UP) + .doubleValue(); + } + + private Map buildRatingDistribution(Long companyId) { + Map distribution = new LinkedHashMap<>(); + for (int rating = 1; rating <= 5; rating++) { + distribution.put("rating" + rating, 0); + } + + List rows = + jobReviewRepository.findPublishedRatingDistributionByCompanyId(companyId); + if (rows == null) { + return distribution; + } + for (Object[] row : rows) { + if (row == null || row.length < 2 + || !(row[0] instanceof Number rating) + || !(row[1] instanceof Number count)) { + continue; + } + int ratingValue = rating.intValue(); + if (ratingValue >= 1 && ratingValue <= 5) { + distribution.put("rating" + ratingValue, count.intValue()); + } + } + return distribution; + } + + private List buildTopTags(Long companyId) { + Map counts = new HashMap<>(); + List rawTags = jobReviewRepository.findPublishedTagsByCompanyId(companyId); + if (rawTags != null) { + for (String rawTag : rawTags) { + for (String tag : parseTags(rawTag)) { + counts.merge(tag, 1L, Long::sum); + } + } + } + + return counts.entrySet().stream() + .sorted((left, right) -> { + int byCount = Long.compare(right.getValue(), left.getValue()); + return byCount != 0 ? byCount : left.getKey().compareTo(right.getKey()); + }) + .limit(MAX_TOP_TAGS) + .map(Map.Entry::getKey) + .toList(); + } + + private List parseTags(String raw) { + if (!StringUtils.hasText(raw)) { + return List.of(); + } + Object parsed = parseJson(raw); + for (int depth = 0; depth < 2 && parsed instanceof String nested; depth++) { + parsed = parseJson(nested); + } + if (!(parsed instanceof Collection values)) { + return List.of(); + } + + List tags = new ArrayList<>(); + for (Object value : values) { + if (value instanceof String tag && StringUtils.hasText(tag)) { + tags.add(tag.trim()); + } + } + return tags; + } + + private Object parseJson(String raw) { + try { + return OBJECT_MAPPER.readValue(raw, Object.class); + } catch (JsonProcessingException ex) { + return null; + } + } + + private Map buildDimensionSummary( + Long companyId) { + Map rowsByCode = new HashMap<>(); + List rows = + dimensionScoreRepository.aggregatePublishedDimensionsByCompanyId(companyId); + if (rows != null) { + for (Object[] row : rows) { + if (row != null && row.length >= 3 && row[0] instanceof String code) { + rowsByCode.put(code, row); + } + } + } + + Map result = new LinkedHashMap<>(); + for (ReviewDimension dimension : ReviewDimension.values()) { + Object[] row = rowsByCode.get(dimension.name()); + long sampleCount = 0L; + Double averageScore = null; + if (row != null && row[2] instanceof Number count) { + sampleCount = count.longValue(); + } + if (row != null && row[1] instanceof Number average) { + averageScore = BigDecimal.valueOf(average.doubleValue()) + .setScale(1, RoundingMode.HALF_UP) + .doubleValue(); + } + result.put(dimension.name(), ReviewSummaryView.DimensionSummaryView.builder() + .averageScore(averageScore) + .sampleCount(sampleCount) + .dataState(sampleCount >= MINIMUM_PUBLIC_REVIEWS + ? SUFFICIENT : INSUFFICIENT) + .build()); + } + return result; + } + + private CompanyKnowledgeView toConfiguredView(CompanyKnowledge knowledge) { + return CompanyKnowledgeView.builder() + .configured(true) + .culture(knowledge.getCulture()) + .workPolicies(knowledge.getWorkPolicies()) + .onboardingGuide(knowledge.getOnboardingGuide()) + .updatedAt(knowledge.getUpdatedAt()) + .version(knowledge.getVersion() == null ? 0L : knowledge.getVersion()) + .build(); + } + + private CompanyKnowledgeOverviewResponse.KnowledgeView toKnowledgeView( + CompanyKnowledge knowledge) { + return CompanyKnowledgeOverviewResponse.KnowledgeView.builder() + .culture(knowledge.getCulture()) + .workPolicies(knowledge.getWorkPolicies()) + .onboardingGuide(knowledge.getOnboardingGuide()) + .updatedAt(knowledge.getUpdatedAt()) + .build(); + } + + private CompanyKnowledgeOverviewResponse.CompanyView toCompanyView(Company company) { + return CompanyKnowledgeOverviewResponse.CompanyView.builder() + .id(company.getId()) + .name(company.getName()) + .logoUrl(company.getLogoUrl()) + .description(company.getDescription()) + .industry(company.getIndustry()) + .website(company.getWebsite()) + .address(company.getAddress()) + .verified(company.getVerified()) + .build(); + } + + private Company requireOwnedCompany(Long userId) { + assertCompanyRole(userId); + return com.mydreamjob.company.VerifiedCompanyGuard.requireVerified( + companyRepository.findByOwnerUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND))); + } + + private void assertCompanyRole(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if (user.getRoles() == null + || user.getRoles().stream().noneMatch(role -> role == User.Role.COMPANY)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } + + private void assertStudentRole(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if (user.getRoles() == null + || user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } + + private record KnowledgeContent( + String culture, + String workPolicies, + String onboardingGuide) { + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java new file mode 100644 index 0000000..4308d9b --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java @@ -0,0 +1,120 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemCreateRequest; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemView; +import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse; +import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; +import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + + +@Tag(name = "入职清单", description = "学生读取有效入职清单并更新本人完成状态") +@RestController +@RequestMapping("/onboarding") +@RequiredArgsConstructor +public class OnboardingChecklistController { + + private final OnboardingChecklistService onboardingChecklistService; + + @Operation(summary = "读取有效入职清单", description = "返回通用项与企业活动自定义项的合并结果") + @GetMapping("/companies/{companyId}/checklist") + public ResponseEntity> getEffectiveChecklist( + @PathVariable Long companyId, + HttpServletRequest request) { + Long userId = resolveUserId(); + EffectiveChecklistResponse data = + onboardingChecklistService.getEffectiveChecklist(userId, companyId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "更新清单条目完成状态", description = "使用绝对状态语义设置当前学生的完成状态") + @PutMapping("/companies/{companyId}/checklist/items/{itemId}/completion") + public ResponseEntity> updateCompletion( + @PathVariable Long companyId, + @PathVariable Long itemId, + @Valid @RequestBody ProgressUpdateRequest body, + HttpServletRequest request) { + Long userId = resolveUserId(); + ProgressUpdateResponse data = onboardingChecklistService.updateCompletion( + userId, companyId, itemId, body); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "查询本企业自定义清单项", description = "返回活动与软删除项及当前版本") + @GetMapping("/company/checklist-items") + public ResponseEntity>> listCompanyChecklistItems( + HttpServletRequest request) { + Long userId = resolveUserId(); + List data = + onboardingChecklistService.listCompanyChecklistItems(userId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "创建本企业自定义清单项", description = "支持 idempotencyKey 重放与冲突检测") + @PostMapping("/company/checklist-items") + public ResponseEntity> createCompanyChecklistItem( + @Valid @RequestBody CompanyChecklistItemCreateRequest body, + HttpServletRequest request) { + Long userId = resolveUserId(); + CompanyChecklistItemView data = + onboardingChecklistService.createCompanyChecklistItem(userId, body); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "更新本企业自定义清单项", description = "使用 version 执行乐观锁控制") + @PutMapping("/company/checklist-items/{itemId}") + public ResponseEntity> updateCompanyChecklistItem( + @PathVariable Long itemId, + @Valid @RequestBody CompanyChecklistItemUpdateRequest body, + HttpServletRequest request) { + Long userId = resolveUserId(); + CompanyChecklistItemView data = + onboardingChecklistService.updateCompanyChecklistItem(userId, itemId, body); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "软删除本企业自定义清单项", description = "重复删除保持幂等成功") + @DeleteMapping("/company/checklist-items/{itemId}") + public ResponseEntity> deleteCompanyChecklistItem( + @PathVariable Long itemId, + @RequestParam Long version, + HttpServletRequest request) { + Long userId = resolveUserId(); + CompanyChecklistItemView data = + onboardingChecklistService.deleteCompanyChecklistItem(userId, itemId, version); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + private Long resolveUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + return Long.valueOf(authentication.getName()); + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItem.java new file mode 100644 index 0000000..f503449 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItem.java @@ -0,0 +1,95 @@ +package com.mydreamjob.onboarding; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table( + name = "onboarding_checklist_items", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_onboarding_checklist_scope_item", + columnNames = {"scope_type", "scope_id", "item_key"}), + @UniqueConstraint( + name = "uk_onboarding_checklist_company_idempotency", + columnNames = {"company_id", "idempotency_key"}) + }) +@EntityListeners(AuditingEntityListener.class) +public class OnboardingChecklistItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "scope_type", nullable = false, length = 20) + private String scopeType; + + @Column(name = "scope_id", nullable = false) + private Long scopeId; + + @Column(name = "company_id") + private Long companyId; + + @Column(name = "item_key", nullable = false, length = 80) + private String itemKey; + + @Column(nullable = false, length = 120) + private String title; + + @Column(length = 1000) + private String description; + + @Column(nullable = false, length = 30) + private String category; + + @Column(name = "sort_order", nullable = false) + @Builder.Default + private Integer sortOrder = 1000; + + @Column(nullable = false) + @Builder.Default + private Boolean required = false; + + @Column(nullable = false) + @Builder.Default + private Boolean active = true; + + @Column(name = "idempotency_key", length = 64) + private String idempotencyKey; + + @Column(name = "request_hash", columnDefinition = "CHAR(64)") + private String requestHash; + + @Version + @Column(nullable = false) + @Builder.Default + private Long version = 0L; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java new file mode 100644 index 0000000..c6a0217 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java @@ -0,0 +1,39 @@ +package com.mydreamjob.onboarding; + +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface OnboardingChecklistItemRepository extends JpaRepository { + + @Query(""" + SELECT item + FROM OnboardingChecklistItem item + WHERE item.active = true + AND ( + item.scopeType = 'STANDARD' + OR (item.scopeType = 'COMPANY' + AND item.scopeId = :companyId + AND item.companyId = :companyId) + ) + ORDER BY item.sortOrder ASC, item.id ASC + """) + List findEffectiveChecklistItems(@Param("companyId") Long companyId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + SELECT item + FROM OnboardingChecklistItem item + WHERE item.scopeType = 'COMPANY' + AND item.scopeId = :companyId + AND item.companyId = :companyId + ORDER BY item.id ASC + """) + List findCompanyItemsForUpdate(@Param("companyId") Long companyId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java new file mode 100644 index 0000000..04753d3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java @@ -0,0 +1,561 @@ +package com.mydreamjob.onboarding; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import jakarta.persistence.PersistenceContext; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.onboarding.dto.ChecklistItemView; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemCreateRequest; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemView; +import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse; +import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; +import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.HexFormat; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class OnboardingChecklistService { + + private static final String STANDARD_SOURCE = "STANDARD"; + private static final String COMPANY_SOURCE = "COMPANY"; + private static final int DEFAULT_SORT_ORDER = 1000; + private static final int MAX_ACTIVE_COMPANY_ITEMS = 50; + private static final int MAX_TITLE_LENGTH = 120; + private static final int MAX_DESCRIPTION_LENGTH = 1000; + private static final int MAX_IDEMPOTENCY_KEY_LENGTH = 64; + private static final Set ALLOWED_CATEGORIES = Set.of( + "DOCUMENT", "ADMIN", "EQUIPMENT", "TRAINING", "LOGISTICS", "OTHER"); + + private final UserRepository userRepository; + private final CompanyRepository companyRepository; + private final OnboardingChecklistItemRepository checklistItemRepository; + private final StudentOnboardingProgressRepository progressRepository; + + @PersistenceContext + private EntityManager entityManager; + + @Transactional(readOnly = true) + public EffectiveChecklistResponse getEffectiveChecklist(Long userId, Long companyId) { + assertStudentRole(userId); + Company company = companyRepository.findById(companyId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + + List items = sortedItems( + checklistItemRepository.findEffectiveChecklistItems(companyId)); + List progressRows = loadProgress(userId, items); + return toEffectiveChecklistResponse(company, items, progressRows); + } + + @Transactional + public ProgressUpdateResponse updateCompletion( + Long userId, + Long companyId, + Long itemId, + ProgressUpdateRequest request) { + assertStudentRole(userId); + lockUser(userId); + if (request == null || request.getCompleted() == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + companyRepository.findById(companyId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + + List items = sortedItems( + checklistItemRepository.findEffectiveChecklistItems(companyId)); + OnboardingChecklistItem item = items.stream() + .filter(candidate -> Objects.equals(candidate.getId(), itemId)) + .findFirst() + .orElseThrow(() -> new BusinessException( + ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND)); + + List progressRows = + new ArrayList<>(loadProgress(userId, items)); + StudentOnboardingProgress current = + progressRepository.findByStudentUserIdAndItemId(userId, itemId).orElse(null); + boolean desired = Boolean.TRUE.equals(request.getCompleted()); + + if (current == null + && request.getVersion() != null + && request.getVersion() != 0L) { + throw new BusinessException( + ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE); + } + + if (current != null) { + boolean actual = Boolean.TRUE.equals(current.getCompleted()); + if (actual != desired) { + if (!Objects.equals(request.getVersion(), current.getVersion())) { + throw new BusinessException( + ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE); + } + current.setCompleted(desired); + current.setCompletedAt(desired ? LocalDateTime.now() : null); + saveProgress(current); + } + } else if (desired) { + current = StudentOnboardingProgress.builder() + .studentUserId(userId) + .itemId(itemId) + .completed(true) + .completedAt(LocalDateTime.now()) + .build(); + progressRows.add(current); + saveProgress(current); + } + + return toProgressUpdateResponse(item, current, items, progressRows); + } + + @Transactional(readOnly = true) + public List listCompanyChecklistItems(Long userId) { + Company company = requireCompanyOwner(userId); + return ownedCompanyItems(checklistItemRepository.findAll(), company.getId()).stream() + .sorted(Comparator + .comparing(OnboardingChecklistItem::getSortOrder, + Comparator.nullsLast(Integer::compareTo)) + .thenComparing(OnboardingChecklistItem::getId, + Comparator.nullsLast(Long::compareTo))) + .map(this::toCompanyChecklistItemView) + .toList(); + } + + @Transactional(isolation = Isolation.READ_COMMITTED) + public CompanyChecklistItemView createCompanyChecklistItem( + Long userId, + CompanyChecklistItemCreateRequest request) { + Company company = requireCompanyOwner(userId); + validateCompanyItemCreateRequest(request); + lockCompany(company); + + String title = normalizeOptional(request.getTitle()); + String description = normalizeOptional(request.getDescription()); + String category = request.getCategory(); + int sortOrder = request.getSortOrder() == null + ? DEFAULT_SORT_ORDER : request.getSortOrder(); + boolean required = Boolean.TRUE.equals(request.getRequired()); + String idempotencyKey = normalizeIdempotencyKey(request.getIdempotencyKey()); + String requestHash = hashCompanyItemRequest( + title, description, category, sortOrder, required); + + List companyItems = + checklistItemRepository.findCompanyItemsForUpdate(company.getId()); + if (idempotencyKey != null) { + OnboardingChecklistItem existing = companyItems.stream() + .filter(item -> Objects.equals(item.getIdempotencyKey(), idempotencyKey)) + .findFirst() + .orElse(null); + if (existing != null) { + if (Objects.equals(existing.getRequestHash(), requestHash)) { + return toCompanyChecklistItemView(existing); + } + throw new BusinessException(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT); + } + } + + long activeCount = companyItems.stream() + .filter(item -> Boolean.TRUE.equals(item.getActive())) + .count(); + if (activeCount >= MAX_ACTIVE_COMPANY_ITEMS) { + throw new BusinessException(ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED); + } + + OnboardingChecklistItem item = OnboardingChecklistItem.builder() + .scopeType(COMPANY_SOURCE) + .scopeId(company.getId()) + .companyId(company.getId()) + .itemKey("company-" + company.getId() + "-" + UUID.randomUUID()) + .title(title) + .description(description) + .category(category) + .sortOrder(sortOrder) + .required(required) + .active(true) + .idempotencyKey(idempotencyKey) + .requestHash(requestHash) + .build(); + return toCompanyChecklistItemView(saveNewCompanyItem(item)); + } + + @Transactional + public CompanyChecklistItemView updateCompanyChecklistItem( + Long userId, + Long itemId, + CompanyChecklistItemUpdateRequest request) { + Company company = requireCompanyOwner(userId); + validateCompanyItemUpdateRequest(request); + lockCompany(company); + OnboardingChecklistItem item = requireOwnedCompanyItem(itemId, company.getId()); + if (!Objects.equals(item.getVersion(), request.getVersion())) { + throw new BusinessException( + ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE); + } + + item.setTitle(normalizeOptional(request.getTitle())); + item.setDescription(normalizeOptional(request.getDescription())); + item.setCategory(request.getCategory()); + item.setSortOrder(request.getSortOrder()); + item.setRequired(request.getRequired()); + item.setActive(request.getActive()); + return toCompanyChecklistItemView(saveCompanyItem(item)); + } + + @Transactional + public CompanyChecklistItemView deleteCompanyChecklistItem( + Long userId, + Long itemId, + Long version) { + Company company = requireCompanyOwner(userId); + lockCompany(company); + OnboardingChecklistItem item = requireOwnedCompanyItem(itemId, company.getId()); + if (Boolean.TRUE.equals(item.getActive())) { + if (!Objects.equals(item.getVersion(), version)) { + throw new BusinessException( + ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE); + } + item.setActive(false); + item = saveCompanyItem(item); + } + return toCompanyChecklistItemView(item); + } + + private Company requireCompanyOwner(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + boolean isCompany = user.getRoles() != null + && user.getRoles().stream().anyMatch(role -> role == User.Role.COMPANY); + if (!isCompany) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + return com.mydreamjob.company.VerifiedCompanyGuard.requireVerified( + companyRepository.findByOwnerUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND))); + } + + private List ownedCompanyItems( + List items, + Long companyId) { + if (items == null || items.isEmpty()) { + return List.of(); + } + return items.stream() + .filter(item -> belongsToCompany(item, companyId)) + .toList(); + } + + private boolean belongsToCompany(OnboardingChecklistItem item, Long companyId) { + return item != null + && COMPANY_SOURCE.equals(item.getScopeType()) + && Objects.equals(item.getScopeId(), companyId) + && Objects.equals(item.getCompanyId(), companyId); + } + + private OnboardingChecklistItem requireOwnedCompanyItem(Long itemId, Long companyId) { + return checklistItemRepository.findById(itemId) + .filter(item -> belongsToCompany(item, companyId)) + .orElseThrow(() -> new BusinessException( + ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND)); + } + + private void validateCompanyItemCreateRequest(CompanyChecklistItemCreateRequest request) { + if (request == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + String title = normalizeOptional(request.getTitle()); + if (title == null || title.length() > MAX_TITLE_LENGTH) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + String description = normalizeOptional(request.getDescription()); + if (description != null && description.length() > MAX_DESCRIPTION_LENGTH) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (!ALLOWED_CATEGORIES.contains(request.getCategory())) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + Integer sortOrder = request.getSortOrder(); + if (sortOrder != null && (sortOrder < 0 || sortOrder > 9999)) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + String idempotencyKey = normalizeIdempotencyKey(request.getIdempotencyKey()); + if (idempotencyKey != null && idempotencyKey.length() > MAX_IDEMPOTENCY_KEY_LENGTH) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + } + + private void validateCompanyItemUpdateRequest(CompanyChecklistItemUpdateRequest request) { + if (request == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + String title = normalizeOptional(request.getTitle()); + if (title == null || title.length() > MAX_TITLE_LENGTH) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + String description = normalizeOptional(request.getDescription()); + if (description != null && description.length() > MAX_DESCRIPTION_LENGTH) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (!ALLOWED_CATEGORIES.contains(request.getCategory()) + || request.getSortOrder() == null + || request.getSortOrder() < 0 + || request.getSortOrder() > 9999 + || request.getRequired() == null + || request.getActive() == null + || request.getVersion() == null + || request.getVersion() < 0) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + } + + private String normalizeOptional(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private String normalizeIdempotencyKey(String value) { + return normalizeOptional(value); + } + + private String hashCompanyItemRequest( + String title, + String description, + String category, + int sortOrder, + boolean required) { + String canonical = "title=" + title.length() + ":" + title + + "|description=" + (description == null + ? "null" : description.length() + ":" + description) + + "|category=" + category + + "|sortOrder=" + sortOrder + + "|required=" + required; + return sha256(canonical); + } + + private String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 not available", ex); + } + } + + private CompanyChecklistItemView toCompanyChecklistItemView(OnboardingChecklistItem item) { + return CompanyChecklistItemView.builder() + .itemId(item.getId()) + .source(COMPANY_SOURCE) + .itemKey(item.getItemKey()) + .title(item.getTitle()) + .description(item.getDescription()) + .category(item.getCategory()) + .required(item.getRequired()) + .sortOrder(item.getSortOrder()) + .active(item.getActive()) + .version(item.getVersion() == null ? 0L : item.getVersion()) + .build(); + } + + private OnboardingChecklistItem saveNewCompanyItem(OnboardingChecklistItem item) { + try { + return checklistItemRepository.saveAndFlush(item); + } catch (DataIntegrityViolationException ex) { + throw new BusinessException(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT, ex); + } + } + + private void lockCompany(Company company) { + if (entityManager == null || company == null || company.getId() == null) { + return; + } + entityManager.lock(company, LockModeType.PESSIMISTIC_WRITE); + } + + private void lockUser(Long userId) { + if (entityManager == null) { + return; + } + User user = entityManager.find(User.class, userId, LockModeType.PESSIMISTIC_WRITE); + if (user == null) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND); + } + } + + private OnboardingChecklistItem saveCompanyItem(OnboardingChecklistItem item) { + try { + return checklistItemRepository.saveAndFlush(item); + } catch (ObjectOptimisticLockingFailureException ex) { + throw new BusinessException( + ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE, ex); + } + } + + private void assertStudentRole(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + boolean isStudent = user.getRoles() != null + && user.getRoles().stream().anyMatch(role -> role == User.Role.STUDENT); + if (!isStudent) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } + + private List sortedItems(List items) { + if (items == null || items.isEmpty()) { + return List.of(); + } + List sorted = new ArrayList<>(items); + sorted.sort(Comparator + .comparing( + OnboardingChecklistItem::getSortOrder, + Comparator.nullsLast(Integer::compareTo)) + .thenComparing( + OnboardingChecklistItem::getId, + Comparator.nullsLast(Long::compareTo))); + return sorted; + } + + private List loadProgress( + Long userId, List items) { + if (items.isEmpty()) { + return List.of(); + } + List itemIds = items.stream() + .map(OnboardingChecklistItem::getId) + .filter(Objects::nonNull) + .toList(); + if (itemIds.isEmpty()) { + return List.of(); + } + return progressRepository.findByStudentUserIdAndItemIdIn(userId, itemIds); + } + + private EffectiveChecklistResponse toEffectiveChecklistResponse( + Company company, + List items, + List progressRows) { + Map progressByItemId = progressRows.stream() + .collect(Collectors.toMap( + StudentOnboardingProgress::getItemId, + Function.identity(), + (first, ignored) -> first)); + + List views = items.stream() + .map(item -> toChecklistItemView(item, progressByItemId.get(item.getId()))) + .toList(); + + int totalItems = views.size(); + int completedItems = completedCount(items, progressRows); + return EffectiveChecklistResponse.builder() + .companyId(company.getId()) + .companyName(company.getName()) + .customized(items.stream() + .anyMatch(item -> COMPANY_SOURCE.equals(item.getScopeType()))) + .totalItems(totalItems) + .completedItems(completedItems) + .completionRate(completionRate(totalItems, completedItems)) + .items(views) + .build(); + + } + + private ProgressUpdateResponse toProgressUpdateResponse( + OnboardingChecklistItem item, + StudentOnboardingProgress progress, + List items, + List progressRows) { + int totalItems = items.size(); + int completedItems = completedCount(items, progressRows); + boolean completed = progress != null && Boolean.TRUE.equals(progress.getCompleted()); + return ProgressUpdateResponse.builder() + .itemId(item.getId()) + .completed(completed) + .completedAt(completed && progress != null ? progress.getCompletedAt() : null) + .progressVersion(progress == null || progress.getVersion() == null + ? 0L : progress.getVersion()) + .totalItems(totalItems) + .completedItems(completedItems) + .completionRate(completionRate(totalItems, completedItems)) + .build(); + } + + private ChecklistItemView toChecklistItemView( + OnboardingChecklistItem item, + StudentOnboardingProgress progress) { + boolean completed = progress != null && Boolean.TRUE.equals(progress.getCompleted()); + return ChecklistItemView.builder() + .itemId(item.getId()) + .source(STANDARD_SOURCE.equals(item.getScopeType()) + ? STANDARD_SOURCE : COMPANY_SOURCE) + .itemKey(item.getItemKey()) + .title(item.getTitle()) + .description(item.getDescription()) + .category(item.getCategory()) + .required(item.getRequired()) + .sortOrder(item.getSortOrder()) + .active(item.getActive()) + .completed(completed) + .completedAt(completed && progress != null ? progress.getCompletedAt() : null) + .progressVersion(progress == null || progress.getVersion() == null + ? 0L : progress.getVersion()) + .build(); + } + + private int completedCount( + List items, + List progressRows) { + var effectiveItemIds = items.stream() + .map(OnboardingChecklistItem::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + return (int) progressRows.stream() + .filter(progress -> effectiveItemIds.contains(progress.getItemId())) + .filter(progress -> Boolean.TRUE.equals(progress.getCompleted())) + .count(); + } + + private double completionRate(int totalItems, int completedItems) { + if (totalItems == 0) { + return 0.0; + } + double rawRate = completedItems * 100.0 / totalItems; + return Math.min(100.0, Math.round(rawRate * 10.0) / 10.0); + } + + private void saveProgress(StudentOnboardingProgress progress) { + try { + progressRepository.saveAndFlush(progress); + } catch (ObjectOptimisticLockingFailureException | DataIntegrityViolationException ex) { + throw new BusinessException( + ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE, ex); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgress.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgress.java new file mode 100644 index 0000000..563c409 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgress.java @@ -0,0 +1,64 @@ +package com.mydreamjob.onboarding; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table( + name = "student_onboarding_progress", + uniqueConstraints = @UniqueConstraint( + name = "uk_student_onboarding_progress_student_item", + columnNames = {"student_user_id", "item_id"})) +@EntityListeners(AuditingEntityListener.class) +public class StudentOnboardingProgress { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "student_user_id", nullable = false) + private Long studentUserId; + + @Column(name = "item_id", nullable = false) + private Long itemId; + + @Column(nullable = false) + @Builder.Default + private Boolean completed = false; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + @Version + @Column(nullable = false) + @Builder.Default + private Long version = 0L; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgressRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgressRepository.java new file mode 100644 index 0000000..599315d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgressRepository.java @@ -0,0 +1,19 @@ +package com.mydreamjob.onboarding; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public interface StudentOnboardingProgressRepository + extends JpaRepository { + + List findByStudentUserIdAndItemIdIn( + Long studentUserId, Collection itemIds); + + Optional findByStudentUserIdAndItemId( + Long studentUserId, Long itemId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ChecklistItemView.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ChecklistItemView.java new file mode 100644 index 0000000..541804f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ChecklistItemView.java @@ -0,0 +1,28 @@ +package com.mydreamjob.onboarding.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChecklistItemView { + + private Long itemId; + private String source; + private String itemKey; + private String title; + private String description; + private String category; + private Boolean required; + private Integer sortOrder; + private Boolean active; + private Boolean completed; + private LocalDateTime completedAt; + private Long progressVersion; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemCreateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemCreateRequest.java new file mode 100644 index 0000000..a3db1cb --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemCreateRequest.java @@ -0,0 +1,34 @@ +package com.mydreamjob.onboarding.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class CompanyChecklistItemCreateRequest { + + @NotBlank(message = "title is required") + @Size(max = 120, message = "title must be at most 120 chars") + private String title; + + @Size(max = 1000, message = "description must be at most 1000 chars") + private String description; + + @NotBlank(message = "category is required") + @Pattern( + regexp = "DOCUMENT|ADMIN|EQUIPMENT|TRAINING|LOGISTICS|OTHER", + message = "category is invalid") + private String category; + + @Min(value = 0, message = "sortOrder must not be negative") + @Max(value = 9999, message = "sortOrder must be at most 9999") + private Integer sortOrder = 1000; + + private Boolean required = false; + + @Size(max = 64, message = "idempotencyKey must be at most 64 chars") + private String idempotencyKey; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemUpdateRequest.java new file mode 100644 index 0000000..26e4e51 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemUpdateRequest.java @@ -0,0 +1,41 @@ +package com.mydreamjob.onboarding.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class CompanyChecklistItemUpdateRequest { + + @NotBlank(message = "title is required") + @Size(max = 120, message = "title must be at most 120 chars") + private String title; + + @Size(max = 1000, message = "description must be at most 1000 chars") + private String description; + + @NotBlank(message = "category is required") + @Pattern( + regexp = "DOCUMENT|ADMIN|EQUIPMENT|TRAINING|LOGISTICS|OTHER", + message = "category is invalid") + private String category; + + @NotNull(message = "sortOrder is required") + @Min(value = 0, message = "sortOrder must not be negative") + @Max(value = 9999, message = "sortOrder must be at most 9999") + private Integer sortOrder; + + @NotNull(message = "required is required") + private Boolean required; + + @NotNull(message = "active is required") + private Boolean active; + + @NotNull(message = "version is required") + @Min(value = 0, message = "version must not be negative") + private Long version; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemView.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemView.java new file mode 100644 index 0000000..18167a6 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemView.java @@ -0,0 +1,24 @@ +package com.mydreamjob.onboarding.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CompanyChecklistItemView { + + private Long itemId; + private String source; + private String itemKey; + private String title; + private String description; + private String category; + private Boolean required; + private Integer sortOrder; + private Boolean active; + private Long version; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java new file mode 100644 index 0000000..ba63c91 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java @@ -0,0 +1,53 @@ +package com.mydreamjob.onboarding.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CompanyKnowledgeOverviewResponse { + + private CompanyView company; + private String knowledgeState; + private KnowledgeView knowledge; + private ReviewSummaryView reviewSummary; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class CompanyView { + + private Long id; + private String name; + private String logoUrl; + private String description; + private String industry; + private String website; + private String address; + private Boolean verified; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class KnowledgeView { + + private String culture; + private String workPolicies; + private String onboardingGuide; + private LocalDateTime updatedAt; + private Long version; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeUpdateRequest.java new file mode 100644 index 0000000..187c699 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeUpdateRequest.java @@ -0,0 +1,21 @@ +package com.mydreamjob.onboarding.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class CompanyKnowledgeUpdateRequest { + + @Size(max = 2000, message = "culture must be at most 2000 chars") + private String culture; + + @Size(max = 4000, message = "workPolicies must be at most 4000 chars") + private String workPolicies; + + @Size(max = 4000, message = "onboardingGuide must be at most 4000 chars") + private String onboardingGuide; + + @Min(value = 0, message = "version must not be negative") + private Long version; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeView.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeView.java new file mode 100644 index 0000000..2fe61a2 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeView.java @@ -0,0 +1,24 @@ +package com.mydreamjob.onboarding.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CompanyKnowledgeView { + + private Boolean configured; + private String culture; + private String workPolicies; + private String onboardingGuide; + private LocalDateTime updatedAt; + private Long version; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/EffectiveChecklistResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/EffectiveChecklistResponse.java new file mode 100644 index 0000000..f3740ec --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/EffectiveChecklistResponse.java @@ -0,0 +1,23 @@ +package com.mydreamjob.onboarding.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EffectiveChecklistResponse { + + private Long companyId; + private String companyName; + private Boolean customized; + private Integer totalItems; + private Integer completedItems; + private Double completionRate; + private List items; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateRequest.java new file mode 100644 index 0000000..d6b5d89 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateRequest.java @@ -0,0 +1,15 @@ +package com.mydreamjob.onboarding.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class ProgressUpdateRequest { + + @NotNull(message = "completed is required") + private Boolean completed; + + @Min(value = 0, message = "version must not be negative") + private Long version; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateResponse.java new file mode 100644 index 0000000..949a10f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateResponse.java @@ -0,0 +1,23 @@ +package com.mydreamjob.onboarding.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ProgressUpdateResponse { + + private Long itemId; + private Boolean completed; + private LocalDateTime completedAt; + private Long progressVersion; + private Integer totalItems; + private Integer completedItems; + private Double completionRate; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ReviewSummaryView.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ReviewSummaryView.java new file mode 100644 index 0000000..8ea8ac4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ReviewSummaryView.java @@ -0,0 +1,37 @@ +package com.mydreamjob.onboarding.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ReviewSummaryView { + + private String state; + private int totalReviews; + private Double averageRating; + private Map ratingDistribution; + private List topTags; + private Map dimensionSummary; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class DimensionSummaryView { + + private Double averageScore; + private long sampleCount; + private String dataState; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java index 9ac6f60..bc5887c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java @@ -42,4 +42,22 @@ public interface JobReviewDimensionScoreRepository extends JpaRepository aggregatePublishedByJobId(@Param("jobId") Long jobId); + /** + * 按公司聚合公开评价八维评分:一次数据库聚合,不加载 JobReview 实体, + * 不先查询 reviewId 列表。返回 [dimensionCode, AVG(score), COUNT(id)]。 + */ + @Query(""" + SELECT d.dimensionCode, AVG(d.score), COUNT(d.id) + FROM JobReviewDimensionScore d + WHERE d.reviewId IN ( + SELECT r.id + FROM JobReview r + WHERE r.jobId IN (SELECT j.id FROM Job j WHERE j.companyId = :companyId) + AND r.status = 'PUBLISHED' + AND r.moderationStatus IN ('AUTO_APPROVED', 'APPROVED') + ) + GROUP BY d.dimensionCode + ORDER BY d.dimensionCode + """) + List aggregatePublishedDimensionsByCompanyId(@Param("companyId") Long companyId); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java index 2f8b347..e5f9fb3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java @@ -14,6 +14,39 @@ import java.util.Optional; @Repository public interface JobReviewRepository extends JpaRepository { + /** 企业公开评价汇总:一次查询统计全部岗位的 PUBLISHED + 审核通过评价。 */ + @Query(""" + SELECT COUNT(r), COALESCE(AVG(r.rating), 0) + FROM JobReview r + WHERE r.jobId IN (SELECT j.id FROM Job j WHERE j.companyId = :companyId) + AND r.status = 'PUBLISHED' + AND r.moderationStatus IN ('AUTO_APPROVED', 'APPROVED') + """) + List countAndAveragePublishedRatingByCompanyId(@Param("companyId") Long companyId); + + /** 企业公开评价评分分布,按评分升序返回 [rating, count]。 */ + @Query(""" + SELECT r.rating, COUNT(r) + FROM JobReview r + WHERE r.jobId IN (SELECT j.id FROM Job j WHERE j.companyId = :companyId) + AND r.status = 'PUBLISHED' + AND r.moderationStatus IN ('AUTO_APPROVED', 'APPROVED') + GROUP BY r.rating + ORDER BY r.rating + """) + List findPublishedRatingDistributionByCompanyId(@Param("companyId") Long companyId); + + /** 企业公开评价的原始 tags 列,避免逐条读取评价。 */ + @Query(""" + SELECT r.tags + FROM JobReview r + WHERE r.jobId IN (SELECT j.id FROM Job j WHERE j.companyId = :companyId) + AND r.status = 'PUBLISHED' + AND r.moderationStatus IN ('AUTO_APPROVED', 'APPROVED') + AND r.tags IS NOT NULL + ORDER BY r.id ASC + """) + List findPublishedTagsByCompanyId(@Param("companyId") Long companyId); Optional findByJobIdAndStudentUserId(Long jobId, Long studentUserId); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java new file mode 100644 index 0000000..f69a0d2 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java @@ -0,0 +1,91 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.api.PageResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationResponse; +import com.mydreamjob.salarycalculator.dto.SalaryHistoryDetailResponse; +import com.mydreamjob.salarycalculator.dto.SalaryHistorySummaryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "薪资计算器", description = "城市政策、到手工资估算与历史记录") +@RestController +@RequestMapping("/salary-calculator") +@RequiredArgsConstructor +public class SalaryCalculationController { + + private final SalaryCalculationService calculationService; + private final SalaryHistoryService historyService; + + @Operation(summary = "计算并保存到手工资", description = "仅 STUDENT;校验政策版本,支持幂等键") + @PostMapping("/calculations") + public ResponseEntity> calculate( + @Valid @RequestBody SalaryCalculationRequest body, HttpServletRequest request) { + Long userId = resolveUserId(); + return ResponseEntity.ok(ApiResponse.ok( + calculationService.calculateAndSave(userId, body), getRequestId(request))); + } + + @Operation(summary = "查询本人薪资历史", description = "仅 STUDENT;按创建时间倒序分页") + @GetMapping("/calculations") + public ResponseEntity>> list( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Long userId = resolveUserId(); + return ResponseEntity.ok(ApiResponse.ok( + historyService.list(userId, page, size), getRequestId(request))); + } + + @Operation(summary = "查询本人薪资历史详情", description = "仅 STUDENT;返回完整快照") + @GetMapping("/calculations/{id}") + public ResponseEntity> get( + @PathVariable Long id, HttpServletRequest request) { + Long userId = resolveUserId(); + return ResponseEntity.ok(ApiResponse.ok( + historyService.get(userId, id), getRequestId(request))); + } + + @Operation(summary = "删除本人薪资历史", description = "仅 STUDENT;物理删除") + @DeleteMapping("/calculations/{id}") + public ResponseEntity> delete( + @PathVariable Long id, HttpServletRequest request) { + Long userId = resolveUserId(); + historyService.delete(userId, id); + return ResponseEntity.ok(ApiResponse.ok(null, getRequestId(request))); + } + + private Long resolveUserId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated() + || "anonymousUser".equals(String.valueOf(auth.getPrincipal()))) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + try { + return Long.valueOf(auth.getName()); + } catch (NumberFormatException ex) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationEngine.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationEngine.java new file mode 100644 index 0000000..39b4bbf --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationEngine.java @@ -0,0 +1,262 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; + +@Component +public class SalaryCalculationEngine { + + public static final String TAX_RULES_VERSION = "CN_IIT_2019_V1"; + + private static final BigDecimal ZERO = new BigDecimal("0"); + private static final BigDecimal MONTHLY_BASIC_DEDUCTION = new BigDecimal("5000"); + private static final BigDecimal MAX_INPUT_AMOUNT = new BigDecimal("10000000"); + private static final BigDecimal MAX_ILLNESS_FEE = new BigDecimal("10000"); + private static final BigDecimal MAX_PENSION_RATE = new BigDecimal("0.20"); + private static final BigDecimal MAX_MEDICAL_RATE = new BigDecimal("0.20"); + private static final BigDecimal MAX_UNEMPLOYMENT_RATE = new BigDecimal("0.10"); + private static final BigDecimal MAX_HOUSING_FUND_RATE = new BigDecimal("0.12"); + private static final int MONEY_SCALE = 2; + + private static final List ANNUAL_BRACKETS = List.of( + new TaxBracket(new BigDecimal("36000"), new BigDecimal("0.03"), new BigDecimal("0")), + new TaxBracket(new BigDecimal("144000"), new BigDecimal("0.10"), new BigDecimal("2520")), + new TaxBracket(new BigDecimal("300000"), new BigDecimal("0.20"), new BigDecimal("16920")), + new TaxBracket(new BigDecimal("420000"), new BigDecimal("0.25"), new BigDecimal("31920")), + new TaxBracket(new BigDecimal("660000"), new BigDecimal("0.30"), new BigDecimal("52920")), + new TaxBracket(new BigDecimal("960000"), new BigDecimal("0.35"), new BigDecimal("85920")), + new TaxBracket(null, new BigDecimal("0.45"), new BigDecimal("181920"))); + + public SalaryCalculationResult calculate(SalaryCalculationInput input, SalaryCityPolicy policy) { + if (input == null || policy == null) { + throw invalidParameter("input and city policy are required"); + } + int monthIndex = input.getMonthIndex() == null ? 1 : input.getMonthIndex(); + BigDecimal gross = requiredMoney(input.getGrossMonthlySalary(), "grossMonthlySalary"); + BigDecimal specialDeduction = optionalMoney( + input.getSpecialAdditionalDeduction(), "specialAdditionalDeduction", MAX_INPUT_AMOUNT); + SalaryOverrideParameters overrides = input.getOverrides(); + + if (gross.compareTo(ZERO) <= 0 || gross.compareTo(MAX_INPUT_AMOUNT) > 0) { + throw invalidParameter("grossMonthlySalary must be within (0, 10000000]"); + } + if (monthIndex < 1 || monthIndex > 12) { + throw invalidParameter("monthIndex must be between 1 and 12"); + } + + BigDecimal pensionRate = effectiveRate( + overrides == null ? null : overrides.getPensionRate(), + policy.getPensionRate(), MAX_PENSION_RATE, "pensionRate"); + BigDecimal medicalRate = effectiveRate( + overrides == null ? null : overrides.getMedicalRate(), + policy.getMedicalRate(), MAX_MEDICAL_RATE, "medicalRate"); + BigDecimal unemploymentRate = effectiveRate( + overrides == null ? null : overrides.getUnemploymentRate(), + policy.getUnemploymentRate(), MAX_UNEMPLOYMENT_RATE, "unemploymentRate"); + BigDecimal housingFundRate = effectiveRate( + overrides == null ? null : overrides.getHousingFundRate(), + policy.getHousingFundRate(), MAX_HOUSING_FUND_RATE, "housingFundRate"); + BigDecimal illnessFee = effectiveFee( + overrides == null ? null : overrides.getSeriousIllnessMonthlyFee(), + policy.getSeriousIllnessMonthlyFee()); + + BigDecimal socialSecurityBase = effectiveBase( + overrides == null ? null : overrides.getSocialSecurityBase(), + gross, + policy.getSocialSecurityBaseMin(), + policy.getSocialSecurityBaseMax(), + "socialSecurityBase"); + BigDecimal housingFundBase = effectiveBase( + overrides == null ? null : overrides.getHousingFundBase(), + gross, + policy.getHousingFundBaseMin(), + policy.getHousingFundBaseMax(), + "housingFundBase"); + + if (socialSecurityBase.compareTo(ZERO) == 0 + && (pensionRate.signum() != 0 || medicalRate.signum() != 0 + || unemploymentRate.signum() != 0)) { + throw invalidParameter("socialSecurityBase may be zero only when social insurance rates are zero"); + } + if (housingFundBase.compareTo(ZERO) == 0 && housingFundRate.signum() != 0) { + throw invalidParameter("housingFundBase may be zero only when housingFundRate is zero"); + } + + BigDecimal pension = socialSecurityBase.multiply(pensionRate); + BigDecimal medical = socialSecurityBase.multiply(medicalRate); + BigDecimal unemployment = socialSecurityBase.multiply(unemploymentRate); + BigDecimal housingFund = housingFundBase.multiply(housingFundRate); + BigDecimal monthlySocialInsurance = pension.add(medical).add(unemployment); + + BigDecimal cumulativeGross = gross.multiply(BigDecimal.valueOf(monthIndex)); + BigDecimal cumulativeSocialInsurance = monthlySocialInsurance.multiply(BigDecimal.valueOf(monthIndex)); + BigDecimal cumulativeHousingFund = housingFund.multiply(BigDecimal.valueOf(monthIndex)); + BigDecimal cumulativeIllness = illnessFee.multiply(BigDecimal.valueOf(monthIndex)); + BigDecimal cumulativeBasicDeduction = MONTHLY_BASIC_DEDUCTION.multiply(BigDecimal.valueOf(monthIndex)); + BigDecimal cumulativeSpecialDeduction = specialDeduction.multiply(BigDecimal.valueOf(monthIndex)); + + BigDecimal cumulativeTaxable = nonNegative(cumulativeGross + .subtract(cumulativeSocialInsurance) + .subtract(cumulativeHousingFund) + .subtract(cumulativeIllness) + .subtract(cumulativeBasicDeduction) + .subtract(cumulativeSpecialDeduction)); + BigDecimal cumulativeTaxDue = taxFor(cumulativeTaxable); + + int priorMonths = monthIndex - 1; + BigDecimal priorTaxable = nonNegative(gross.multiply(BigDecimal.valueOf(priorMonths)) + .subtract(monthlySocialInsurance.multiply(BigDecimal.valueOf(priorMonths))) + .subtract(housingFund.multiply(BigDecimal.valueOf(priorMonths))) + .subtract(illnessFee.multiply(BigDecimal.valueOf(priorMonths))) + .subtract(MONTHLY_BASIC_DEDUCTION.multiply(BigDecimal.valueOf(priorMonths))) + .subtract(specialDeduction.multiply(BigDecimal.valueOf(priorMonths)))); + BigDecimal priorTax = taxFor(priorTaxable); + BigDecimal currentTax = nonNegative(cumulativeTaxDue.subtract(priorTax)); + BigDecimal netSalary = nonNegative(gross + .subtract(pension) + .subtract(medical) + .subtract(unemployment) + .subtract(housingFund) + .subtract(illnessFee) + .subtract(currentTax)); + + return SalaryCalculationResult.builder() + .cityCode(policy.getCityCode()) + .cityName(policy.getCityName()) + .dataLevel(policy.getDataLevel()) + .policyVersion(policy.getPolicyVersion()) + .policyEffectiveDate(policy.getPolicyEffectiveDate()) + .taxRulesVersion(TAX_RULES_VERSION) + .estimate(true) + .monthIndex(monthIndex) + .grossMonthlySalary(money(gross)) + .specialAdditionalDeduction(money(specialDeduction)) + .socialSecurityBase(money(socialSecurityBase)) + .housingFundBase(money(housingFundBase)) + .pensionRate(rate(pensionRate)) + .medicalRate(rate(medicalRate)) + .unemploymentRate(rate(unemploymentRate)) + .housingFundRate(rate(housingFundRate)) + .seriousIllnessMonthlyFee(money(illnessFee)) + .pension(money(pension)) + .medical(money(medical)) + .unemployment(money(unemployment)) + .housingFund(money(housingFund)) + .cumulativeGrossIncome(money(cumulativeGross)) + .cumulativeSocialInsurance(money(cumulativeSocialInsurance)) + .cumulativeHousingFund(money(cumulativeHousingFund)) + .cumulativeSeriousIllness(money(cumulativeIllness)) + .cumulativeBasicDeduction(money(cumulativeBasicDeduction)) + .cumulativeSpecialAdditionalDeduction(money(cumulativeSpecialDeduction)) + .cumulativeTaxableIncome(money(cumulativeTaxable)) + .cumulativeTaxDue(money(cumulativeTaxDue)) + .previouslyWithheldTax(money(priorTax)) + .currentMonthTax(money(currentTax)) + .netSalary(money(netSalary)) + .build(); + } + + private BigDecimal effectiveBase( + BigDecimal override, BigDecimal gross, BigDecimal min, BigDecimal max, String field) { + validatePolicyRange(min, max, field); + if (override != null) { + BigDecimal value = requiredMoney(override, field); + if (value.compareTo(ZERO) < 0) { + throw invalidParameter(field + " must not be negative"); + } + if (value.compareTo(ZERO) > 0 && (value.compareTo(min) < 0 || value.compareTo(max) > 0)) { + throw invalidParameter(field + " must be within city range or zero for an exempt contribution"); + } + return value; + } + return clamp(gross, min, max); + } + + private BigDecimal effectiveRate( + BigDecimal override, BigDecimal fallback, BigDecimal max, String field) { + BigDecimal value = override == null ? fallback : requiredRate(override, field); + if (value == null || value.compareTo(ZERO) < 0 || value.compareTo(max) > 0) { + throw invalidParameter(field + " is outside the allowed range"); + } + return value; + } + + private BigDecimal effectiveFee(BigDecimal override, BigDecimal fallback) { + BigDecimal value = override == null ? fallback : requiredMoney(override, "seriousIllnessMonthlyFee"); + if (value == null || value.compareTo(ZERO) < 0 || value.compareTo(MAX_ILLNESS_FEE) > 0) { + throw invalidParameter("seriousIllnessMonthlyFee is outside the allowed range"); + } + return value; + } + + private BigDecimal taxFor(BigDecimal taxableIncome) { + if (taxableIncome.compareTo(ZERO) <= 0) { + return ZERO; + } + for (TaxBracket bracket : ANNUAL_BRACKETS) { + if (bracket.upperBound == null || taxableIncome.compareTo(bracket.upperBound) <= 0) { + return nonNegative(taxableIncome.multiply(bracket.rate).subtract(bracket.quickDeduction)); + } + } + return ZERO; + } + + private BigDecimal clamp(BigDecimal value, BigDecimal min, BigDecimal max) { + if (value.compareTo(min) < 0) { + return min; + } + return value.compareTo(max) > 0 ? max : value; + } + + private void validatePolicyRange(BigDecimal min, BigDecimal max, String field) { + if (min == null || max == null || min.compareTo(ZERO) < 0 || max.compareTo(min) < 0) { + throw invalidParameter(field + " city range is invalid"); + } + } + + private BigDecimal requiredMoney(BigDecimal value, String field) { + if (value == null || value.scale() > MONEY_SCALE) { + throw invalidParameter(field + " must have at most two decimal places"); + } + return value; + } + + private BigDecimal optionalMoney(BigDecimal value, String field, BigDecimal max) { + BigDecimal effective = value == null ? ZERO : requiredMoney(value, field); + if (effective.compareTo(ZERO) < 0 || effective.compareTo(max) > 0) { + throw invalidParameter(field + " is outside the allowed range"); + } + return effective; + } + + private BigDecimal requiredRate(BigDecimal value, String field) { + if (value == null || value.scale() > 6) { + throw invalidParameter(field + " must have at most six decimal places"); + } + return value; + } + + private BigDecimal nonNegative(BigDecimal value) { + return value.compareTo(ZERO) < 0 ? ZERO : value; + } + + private BigDecimal money(BigDecimal value) { + return value.setScale(MONEY_SCALE, RoundingMode.HALF_UP); + } + + private BigDecimal rate(BigDecimal value) { + return value.setScale(6, RoundingMode.HALF_UP); + } + + private BusinessException invalidParameter(String detail) { + return new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, detail); + } + + private record TaxBracket(BigDecimal upperBound, BigDecimal rate, BigDecimal quickDeduction) { + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistory.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistory.java new file mode 100644 index 0000000..d037e09 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistory.java @@ -0,0 +1,86 @@ +package com.mydreamjob.salarycalculator; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table( + name = "salary_calculation_history", + uniqueConstraints = @UniqueConstraint( + name = "uk_sch_student_idempotency", + columnNames = {"student_user_id", "idempotency_key"})) +@EntityListeners(AuditingEntityListener.class) +public class SalaryCalculationHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "student_user_id", nullable = false) + private Long studentUserId; + + @Column(name = "city_code", nullable = false, length = 6) + private String cityCode; + + @Column(name = "city_name", nullable = false, length = 80) + private String cityName; + + @Enumerated(EnumType.STRING) + @Column(name = "data_level", nullable = false, length = 30) + private SalaryDataLevel dataLevel; + + @Column(name = "policy_version", nullable = false, length = 64) + private String policyVersion; + + @Column(name = "tax_rules_version", nullable = false, length = 64) + private String taxRulesVersion; + + @Column(name = "month_index", nullable = false) + private Integer monthIndex; + + @Column(name = "gross_monthly_salary", nullable = false, precision = 12, scale = 2) + private BigDecimal grossMonthlySalary; + + @Column(name = "net_salary", nullable = false, precision = 12, scale = 2) + private BigDecimal netSalary; + + @Column(name = "idempotency_key", length = 64) + private String idempotencyKey; + + @Column(name = "request_hash", columnDefinition = "CHAR(64)") + private String requestHash; + + @Column(name = "input_snapshot_json", nullable = false, columnDefinition = "TEXT") + private String inputSnapshotJson; + + @Column(name = "policy_snapshot_json", nullable = false, columnDefinition = "TEXT") + private String policySnapshotJson; + + @Column(name = "result_snapshot_json", nullable = false, columnDefinition = "TEXT") + private String resultSnapshotJson; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistoryRepository.java new file mode 100644 index 0000000..70a0c48 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistoryRepository.java @@ -0,0 +1,23 @@ +package com.mydreamjob.salarycalculator; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SalaryCalculationHistoryRepository + extends JpaRepository { + + Page findByStudentUserIdOrderByCreatedAtDescIdDesc( + Long studentUserId, Pageable pageable); + + Optional findByIdAndStudentUserId(Long id, Long studentUserId); + + Optional findByStudentUserIdAndIdempotencyKey( + Long studentUserId, String idempotencyKey); + + long deleteByStudentUserId(Long studentUserId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationInput.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationInput.java new file mode 100644 index 0000000..f7b0f56 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationInput.java @@ -0,0 +1,19 @@ +package com.mydreamjob.salarycalculator; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryCalculationInput { + private BigDecimal grossMonthlySalary; + private Integer monthIndex; + private BigDecimal specialAdditionalDeduction; + private SalaryOverrideParameters overrides; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationResult.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationResult.java new file mode 100644 index 0000000..d35fc9c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationResult.java @@ -0,0 +1,53 @@ +package com.mydreamjob.salarycalculator; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryCalculationResult { + + private String cityCode; + private String cityName; + private SalaryDataLevel dataLevel; + private String policyVersion; + private LocalDate policyEffectiveDate; + private String taxRulesVersion; + private boolean estimate; + + private Integer monthIndex; + private BigDecimal grossMonthlySalary; + private BigDecimal specialAdditionalDeduction; + + private BigDecimal socialSecurityBase; + private BigDecimal housingFundBase; + private BigDecimal pensionRate; + private BigDecimal medicalRate; + private BigDecimal unemploymentRate; + private BigDecimal housingFundRate; + private BigDecimal seriousIllnessMonthlyFee; + + private BigDecimal pension; + private BigDecimal medical; + private BigDecimal unemployment; + private BigDecimal housingFund; + + private BigDecimal cumulativeGrossIncome; + private BigDecimal cumulativeSocialInsurance; + private BigDecimal cumulativeHousingFund; + private BigDecimal cumulativeSeriousIllness; + private BigDecimal cumulativeBasicDeduction; + private BigDecimal cumulativeSpecialAdditionalDeduction; + private BigDecimal cumulativeTaxableIncome; + private BigDecimal cumulativeTaxDue; + private BigDecimal previouslyWithheldTax; + private BigDecimal currentMonthTax; + private BigDecimal netSalary; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java new file mode 100644 index 0000000..83d18f4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java @@ -0,0 +1,181 @@ +package com.mydreamjob.salarycalculator; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationOverrideRequest; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class SalaryCalculationService { + + private final SalaryCityPolicyRepository cityPolicyRepository; + private final SalaryCalculationHistoryRepository historyRepository; + private final UserRepository userRepository; + private final SalaryCalculationEngine calculationEngine; + private final SalaryRequestFingerprint requestFingerprint; + private final ObjectMapper objectMapper; + + @Transactional(isolation = Isolation.READ_COMMITTED) + public SalaryCalculationResponse calculateAndSave(Long userId, SalaryCalculationRequest request) { + String idempotencyKey = normalizeIdempotencyKey(request.getIdempotencyKey()); + String requestHash = idempotencyKey == null ? null : requestFingerprint.fingerprint(request); + // 在读取幂等记录之前锁定用户,跨实例串行化同一用户的写入;锁随事务结束释放。 + requireStudent(userId); + if (idempotencyKey != null) { + var existing = historyRepository.findByStudentUserIdAndIdempotencyKey(userId, idempotencyKey); + if (existing.isPresent()) { + return replay(existing.get(), requestHash); + } + } + + SalaryCityPolicy policy = cityPolicyRepository.findByCityCodeAndEnabledTrue(request.getCityCode()) + .orElseThrow(() -> new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND)); + if (!Objects.equals(policy.getPolicyVersion(), request.getExpectedPolicyVersion())) { + throw new SalaryPolicyVersionConflictException( + policy.getPolicyVersion(), policy.getPolicyEffectiveDate()); + } + + SalaryCalculationResult result = calculationEngine.calculate(toEngineInput(request), policy); + SalaryCalculationHistory history = SalaryCalculationHistory.builder() + .studentUserId(userId) + .cityCode(policy.getCityCode()) + .cityName(policy.getCityName()) + .dataLevel(policy.getDataLevel()) + .policyVersion(policy.getPolicyVersion()) + .taxRulesVersion(result.getTaxRulesVersion()) + .monthIndex(result.getMonthIndex()) + .grossMonthlySalary(result.getGrossMonthlySalary()) + .netSalary(result.getNetSalary()) + .idempotencyKey(idempotencyKey) + .requestHash(requestHash) + .inputSnapshotJson(toJson(inputSnapshot(request))) + .policySnapshotJson(toJson(policySnapshot(policy, result))) + .resultSnapshotJson(toJson(result)) + .build(); + + history = historyRepository.saveAndFlush(history); + return toResponse(history); + } + + private SalaryCalculationResponse replay(SalaryCalculationHistory history, String requestHash) { + if (!Objects.equals(history.getRequestHash(), requestHash)) { + throw new BusinessException(ErrorCode.SALARY_IDEMPOTENCY_CONFLICT); + } + return toResponse(history); + } + + private SalaryCalculationResponse toResponse(SalaryCalculationHistory history) { + try { + SalaryCalculationResult result = objectMapper.readValue( + history.getResultSnapshotJson(), SalaryCalculationResult.class); + return SalaryCalculationResponse.builder() + .historyId(history.getId()) + .createdAt(history.getCreatedAt()) + .result(result) + .build(); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("stored salary result snapshot is invalid", ex); + } + } + + private SalaryCalculationInput toEngineInput(SalaryCalculationRequest request) { + SalaryCalculationOverrideRequest source = request.getOverrides(); + SalaryOverrideParameters overrides = source == null ? null : SalaryOverrideParameters.builder() + .socialSecurityBase(source.getSocialSecurityBase()) + .housingFundBase(source.getHousingFundBase()) + .pensionRate(source.getPensionRate()) + .medicalRate(source.getMedicalRate()) + .unemploymentRate(source.getUnemploymentRate()) + .housingFundRate(source.getHousingFundRate()) + .seriousIllnessMonthlyFee(source.getSeriousIllnessMonthlyFee()) + .build(); + return SalaryCalculationInput.builder() + .grossMonthlySalary(request.getGrossMonthlySalary()) + .monthIndex(request.getMonthIndex()) + .specialAdditionalDeduction(request.getSpecialAdditionalDeduction()) + .overrides(overrides) + .build(); + } + + private Map inputSnapshot(SalaryCalculationRequest request) { + Map input = new LinkedHashMap<>(); + input.put("cityCode", request.getCityCode()); + input.put("grossMonthlySalary", request.getGrossMonthlySalary()); + input.put("monthIndex", request.getMonthIndex() == null ? 1 : request.getMonthIndex()); + input.put("specialAdditionalDeduction", request.getSpecialAdditionalDeduction()); + input.put("expectedPolicyVersion", request.getExpectedPolicyVersion()); + input.put("overrides", request.getOverrides()); + return input; + } + + private Map policySnapshot( + SalaryCityPolicy policy, SalaryCalculationResult result) { + Map snapshot = new LinkedHashMap<>(); + snapshot.put("cityCode", policy.getCityCode()); + snapshot.put("cityName", policy.getCityName()); + snapshot.put("dataLevel", policy.getDataLevel()); + snapshot.put("policyVersion", policy.getPolicyVersion()); + snapshot.put("policyEffectiveDate", policy.getPolicyEffectiveDate()); + snapshot.put("defaultParameters", Map.of( + "socialSecurityBaseMin", policy.getSocialSecurityBaseMin(), + "socialSecurityBaseMax", policy.getSocialSecurityBaseMax(), + "housingFundBaseMin", policy.getHousingFundBaseMin(), + "housingFundBaseMax", policy.getHousingFundBaseMax(), + "pensionRate", policy.getPensionRate(), + "medicalRate", policy.getMedicalRate(), + "unemploymentRate", policy.getUnemploymentRate(), + "housingFundRate", policy.getHousingFundRate(), + "seriousIllnessMonthlyFee", policy.getSeriousIllnessMonthlyFee())); + snapshot.put("effectiveParameters", Map.of( + "socialSecurityBase", result.getSocialSecurityBase(), + "housingFundBase", result.getHousingFundBase(), + "pensionRate", result.getPensionRate(), + "medicalRate", result.getMedicalRate(), + "unemploymentRate", result.getUnemploymentRate(), + "housingFundRate", result.getHousingFundRate(), + "seriousIllnessMonthlyFee", result.getSeriousIllnessMonthlyFee())); + return snapshot; + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("unable to serialize salary snapshot", ex); + } + } + + private String normalizeIdempotencyKey(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim(); + if (normalized.length() > 64) { + throw new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, "idempotencyKey is too long"); + } + return normalized; + } + + private void requireStudent(Long userId) { + User user = userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if (user.getRoles() == null + || user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicy.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicy.java new file mode 100644 index 0000000..d869f8c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicy.java @@ -0,0 +1,97 @@ +package com.mydreamjob.salarycalculator; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "salary_city_policy") +@EntityListeners(AuditingEntityListener.class) +public class SalaryCityPolicy { + + @Id + @Column(name = "city_code", nullable = false, length = 6) + private String cityCode; + + @Column(name = "province_code", nullable = false, length = 6) + private String provinceCode; + + @Column(name = "province_name", nullable = false, length = 40) + private String provinceName; + + @Column(name = "city_name", nullable = false, length = 80) + private String cityName; + + @Column(name = "policy_version", nullable = false, length = 64) + private String policyVersion; + + @Column(name = "policy_effective_date", nullable = false) + private LocalDate policyEffectiveDate; + + @Column(name = "data_source", length = 500) + private String dataSource; + + @Column(name = "social_security_base_min", nullable = false, precision = 12, scale = 2) + private BigDecimal socialSecurityBaseMin; + + @Column(name = "social_security_base_max", nullable = false, precision = 12, scale = 2) + private BigDecimal socialSecurityBaseMax; + + @Column(name = "housing_fund_base_min", nullable = false, precision = 12, scale = 2) + private BigDecimal housingFundBaseMin; + + @Column(name = "housing_fund_base_max", nullable = false, precision = 12, scale = 2) + private BigDecimal housingFundBaseMax; + + @Column(name = "pension_rate", nullable = false, precision = 7, scale = 6) + private BigDecimal pensionRate; + + @Column(name = "medical_rate", nullable = false, precision = 7, scale = 6) + private BigDecimal medicalRate; + + @Column(name = "unemployment_rate", nullable = false, precision = 7, scale = 6) + private BigDecimal unemploymentRate; + + @Column(name = "housing_fund_rate", nullable = false, precision = 7, scale = 6) + private BigDecimal housingFundRate; + + @Column(name = "serious_illness_monthly_fee", nullable = false, precision = 10, scale = 2) + @Builder.Default + private BigDecimal seriousIllnessMonthlyFee = BigDecimal.ZERO; + + @Enumerated(EnumType.STRING) + @Column(name = "data_level", nullable = false, length = 30) + @Builder.Default + private SalaryDataLevel dataLevel = SalaryDataLevel.CITY; + + @Column(nullable = false) + @Builder.Default + private Boolean enabled = true; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyController.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyController.java new file mode 100644 index 0000000..f59d723 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyController.java @@ -0,0 +1,64 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.salarycalculator.dto.SalaryCityDetailResponse; +import com.mydreamjob.salarycalculator.dto.SalaryCitySummaryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "薪资计算器", description = "城市政策、到手工资估算与历史记录") +@RestController +@RequestMapping("/salary-calculator") +@RequiredArgsConstructor +public class SalaryCityPolicyController { + + private final SalaryCityPolicyService cityPolicyService; + + @Operation(summary = "查询启用城市列表", description = "仅 STUDENT;返回全部启用城市摘要,不分页") + @GetMapping("/cities") + public ResponseEntity>> listCities( + HttpServletRequest request) { + Long userId = resolveUserId(); + return ResponseEntity.ok(ApiResponse.ok( + cityPolicyService.listCities(userId), getRequestId(request))); + } + + @Operation(summary = "查询城市默认参数", description = "仅 STUDENT;返回城市元数据和当前整套默认参数") + @GetMapping("/cities/{cityCode}") + public ResponseEntity> getCity( + @PathVariable String cityCode, HttpServletRequest request) { + Long userId = resolveUserId(); + return ResponseEntity.ok(ApiResponse.ok( + cityPolicyService.getCity(userId, cityCode), getRequestId(request))); + } + + private Long resolveUserId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated() + || "anonymousUser".equals(String.valueOf(auth.getPrincipal()))) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + try { + return Long.valueOf(auth.getName()); + } catch (NumberFormatException ex) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyRepository.java new file mode 100644 index 0000000..de5fc12 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyRepository.java @@ -0,0 +1,15 @@ +package com.mydreamjob.salarycalculator; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface SalaryCityPolicyRepository extends JpaRepository { + + List findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc(); + + Optional findByCityCodeAndEnabledTrue(String cityCode); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyService.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyService.java new file mode 100644 index 0000000..b87c16e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyService.java @@ -0,0 +1,81 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.salarycalculator.dto.SalaryCityDetailResponse; +import com.mydreamjob.salarycalculator.dto.SalaryCitySummaryResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class SalaryCityPolicyService { + + private final SalaryCityPolicyRepository cityPolicyRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public List listCities(Long userId) { + requireStudent(userId); + return cityPolicyRepository.findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc() + .stream() + .map(this::toSummary) + .toList(); + } + + @Transactional(readOnly = true) + public SalaryCityDetailResponse getCity(Long userId, String cityCode) { + requireStudent(userId); + if (cityCode == null || !cityCode.matches("\\d{6}")) { + throw new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND); + } + SalaryCityPolicy policy = cityPolicyRepository + .findByCityCodeAndEnabledTrue(cityCode) + .orElseThrow(() -> new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND)); + return SalaryCityDetailResponse.builder() + .cityCode(policy.getCityCode()) + .provinceCode(policy.getProvinceCode()) + .provinceName(policy.getProvinceName()) + .cityName(policy.getCityName()) + .policyVersion(policy.getPolicyVersion()) + .policyEffectiveDate(policy.getPolicyEffectiveDate()) + .dataLevel(policy.getDataLevel()) + .dataSource(policy.getDataSource()) + .socialSecurityBaseMin(policy.getSocialSecurityBaseMin()) + .socialSecurityBaseMax(policy.getSocialSecurityBaseMax()) + .housingFundBaseMin(policy.getHousingFundBaseMin()) + .housingFundBaseMax(policy.getHousingFundBaseMax()) + .pensionRate(policy.getPensionRate()) + .medicalRate(policy.getMedicalRate()) + .unemploymentRate(policy.getUnemploymentRate()) + .housingFundRate(policy.getHousingFundRate()) + .seriousIllnessMonthlyFee(policy.getSeriousIllnessMonthlyFee()) + .build(); + } + + private SalaryCitySummaryResponse toSummary(SalaryCityPolicy policy) { + return SalaryCitySummaryResponse.builder() + .cityCode(policy.getCityCode()) + .provinceCode(policy.getProvinceCode()) + .provinceName(policy.getProvinceName()) + .cityName(policy.getCityName()) + .policyVersion(policy.getPolicyVersion()) + .policyEffectiveDate(policy.getPolicyEffectiveDate()) + .dataLevel(policy.getDataLevel()) + .build(); + } + + private void requireStudent(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if (user.getRoles() == null + || user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryDataLevel.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryDataLevel.java new file mode 100644 index 0000000..f3e055f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryDataLevel.java @@ -0,0 +1,6 @@ +package com.mydreamjob.salarycalculator; + +public enum SalaryDataLevel { + CITY, + PROVINCE_FALLBACK +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryCleanupService.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryCleanupService.java new file mode 100644 index 0000000..8cbb00a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryCleanupService.java @@ -0,0 +1,18 @@ +package com.mydreamjob.salarycalculator; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SalaryHistoryCleanupService { + + private final SalaryCalculationHistoryRepository historyRepository; + + @Transactional(propagation = Propagation.MANDATORY) + public long purgeForDeletedUser(Long userId) { + return historyRepository.deleteByStudentUserId(userId); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryService.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryService.java new file mode 100644 index 0000000..a7da223 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryService.java @@ -0,0 +1,106 @@ +package com.mydreamjob.salarycalculator; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.api.PageResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.salarycalculator.dto.SalaryHistoryDetailResponse; +import com.mydreamjob.salarycalculator.dto.SalaryHistorySummaryResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SalaryHistoryService { + + private final SalaryCalculationHistoryRepository historyRepository; + private final UserRepository userRepository; + private final ObjectMapper objectMapper; + + @Transactional(readOnly = true) + public PageResponse list(Long userId, int page, int size) { + requireStudent(userId); + if (page < 0 || size < 1 || size > 100) { + throw new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, "invalid pagination"); + } + Page result = historyRepository + .findByStudentUserIdOrderByCreatedAtDescIdDesc(userId, PageRequest.of(page, size)); + return PageResponse.of( + result.getContent().stream().map(this::toSummary).toList(), + result.getTotalElements(), page, size); + } + + @Transactional(readOnly = true) + public SalaryHistoryDetailResponse get(Long userId, Long historyId) { + requireStudent(userId); + SalaryCalculationHistory history = ownHistory(userId, historyId); + return SalaryHistoryDetailResponse.builder() + .id(history.getId()) + .cityCode(history.getCityCode()) + .cityName(history.getCityName()) + .dataLevel(history.getDataLevel()) + .policyVersion(history.getPolicyVersion()) + .taxRulesVersion(history.getTaxRulesVersion()) + .monthIndex(history.getMonthIndex()) + .grossMonthlySalary(history.getGrossMonthlySalary()) + .netSalary(history.getNetSalary()) + .createdAt(history.getCreatedAt()) + .inputSnapshot(readJson(history.getInputSnapshotJson())) + .policySnapshot(readJson(history.getPolicySnapshotJson())) + .resultSnapshot(readJson(history.getResultSnapshotJson())) + .build(); + } + + @Transactional + public void delete(Long userId, Long historyId) { + requireStudent(userId); + SalaryCalculationHistory history = ownHistory(userId, historyId); + historyRepository.delete(history); + historyRepository.flush(); + } + + private SalaryHistorySummaryResponse toSummary(SalaryCalculationHistory history) { + return SalaryHistorySummaryResponse.builder() + .id(history.getId()) + .cityCode(history.getCityCode()) + .cityName(history.getCityName()) + .dataLevel(history.getDataLevel()) + .grossMonthlySalary(history.getGrossMonthlySalary()) + .netSalary(history.getNetSalary()) + .monthIndex(history.getMonthIndex()) + .createdAt(history.getCreatedAt()) + .build(); + } + + private SalaryCalculationHistory ownHistory(Long userId, Long historyId) { + if (historyId == null) { + throw new BusinessException(ErrorCode.SALARY_HISTORY_NOT_FOUND); + } + return historyRepository.findByIdAndStudentUserId(historyId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.SALARY_HISTORY_NOT_FOUND)); + } + + private JsonNode readJson(String value) { + try { + return objectMapper.readTree(value); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("stored salary snapshot is invalid", ex); + } + } + + private void requireStudent(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if (user.getRoles() == null + || user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryOverrideParameters.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryOverrideParameters.java new file mode 100644 index 0000000..6ae17a4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryOverrideParameters.java @@ -0,0 +1,22 @@ +package com.mydreamjob.salarycalculator; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryOverrideParameters { + private BigDecimal socialSecurityBase; + private BigDecimal housingFundBase; + private BigDecimal pensionRate; + private BigDecimal medicalRate; + private BigDecimal unemploymentRate; + private BigDecimal housingFundRate; + private BigDecimal seriousIllnessMonthlyFee; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyVersionConflictException.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyVersionConflictException.java new file mode 100644 index 0000000..4da720c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyVersionConflictException.java @@ -0,0 +1,23 @@ +package com.mydreamjob.salarycalculator; + +import java.time.LocalDate; + +public class SalaryPolicyVersionConflictException extends RuntimeException { + + private final String latestPolicyVersion; + private final LocalDate latestPolicyEffectiveDate; + + public SalaryPolicyVersionConflictException(String latestPolicyVersion, LocalDate latestPolicyEffectiveDate) { + super("salary city policy version conflict"); + this.latestPolicyVersion = latestPolicyVersion; + this.latestPolicyEffectiveDate = latestPolicyEffectiveDate; + } + + public String getLatestPolicyVersion() { + return latestPolicyVersion; + } + + public LocalDate getLatestPolicyEffectiveDate() { + return latestPolicyEffectiveDate; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryRequestFingerprint.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryRequestFingerprint.java new file mode 100644 index 0000000..62fe071 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryRequestFingerprint.java @@ -0,0 +1,68 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.salarycalculator.dto.SalaryCalculationOverrideRequest; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +@Component +public class SalaryRequestFingerprint { + + public String fingerprint(SalaryCalculationRequest request) { + StringBuilder canonical = new StringBuilder(); + append(canonical, normalize(request.getCityCode())); + append(canonical, normalize(request.getExpectedPolicyVersion())); + append(canonical, decimal(request.getGrossMonthlySalary())); + append(canonical, String.valueOf(request.getMonthIndex() == null ? 1 : request.getMonthIndex())); + append(canonical, decimal(request.getSpecialAdditionalDeduction() == null + ? BigDecimal.ZERO : request.getSpecialAdditionalDeduction())); + SalaryCalculationOverrideRequest overrides = request.getOverrides(); + if (overrides == null) { + for (int i = 0; i < 7; i++) { + append(canonical, ""); + } + } else { + append(canonical, decimal(overrides.getSocialSecurityBase())); + append(canonical, decimal(overrides.getHousingFundBase())); + append(canonical, decimal(overrides.getPensionRate())); + append(canonical, decimal(overrides.getMedicalRate())); + append(canonical, decimal(overrides.getUnemploymentRate())); + append(canonical, decimal(overrides.getHousingFundRate())); + append(canonical, decimal(overrides.getSeriousIllnessMonthlyFee())); + } + return sha256(canonical.toString()); + } + + private void append(StringBuilder builder, String value) { + builder.append(value.length()).append(':').append(value).append('|'); + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } + + private String decimal(BigDecimal value) { + if (value == null) { + return ""; + } + BigDecimal normalized = value.stripTrailingZeros(); + if (normalized.scale() < 0) { + normalized = normalized.setScale(0); + } + return normalized.toPlainString(); + } + + private String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is unavailable", ex); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationOverrideRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationOverrideRequest.java new file mode 100644 index 0000000..407f27c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationOverrideRequest.java @@ -0,0 +1,53 @@ +package com.mydreamjob.salarycalculator.dto; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryCalculationOverrideRequest { + + @DecimalMin(value = "0.00") + @DecimalMax(value = "10000000.00") + @Digits(integer = 8, fraction = 2) + private BigDecimal socialSecurityBase; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "10000000.00") + @Digits(integer = 8, fraction = 2) + private BigDecimal housingFundBase; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "0.20") + @Digits(integer = 1, fraction = 6) + private BigDecimal pensionRate; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "0.20") + @Digits(integer = 1, fraction = 6) + private BigDecimal medicalRate; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "0.10") + @Digits(integer = 1, fraction = 6) + private BigDecimal unemploymentRate; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "0.12") + @Digits(integer = 1, fraction = 6) + private BigDecimal housingFundRate; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "10000.00") + @Digits(integer = 5, fraction = 2) + private BigDecimal seriousIllnessMonthlyFee; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationRequest.java new file mode 100644 index 0000000..ee141ac --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationRequest.java @@ -0,0 +1,51 @@ +package com.mydreamjob.salarycalculator.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryCalculationRequest { + + @NotBlank + private String cityCode; + + @NotNull + @DecimalMin(value = "0.01") + @DecimalMax(value = "10000000.00") + @Digits(integer = 8, fraction = 2) + private BigDecimal grossMonthlySalary; + + @Min(1) + @Max(12) + private Integer monthIndex; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "10000000.00") + @Digits(integer = 8, fraction = 2) + private BigDecimal specialAdditionalDeduction; + + @NotBlank + private String expectedPolicyVersion; + + @Size(max = 64) + private String idempotencyKey; + + @Valid + private SalaryCalculationOverrideRequest overrides; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationResponse.java new file mode 100644 index 0000000..dd3685d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationResponse.java @@ -0,0 +1,19 @@ +package com.mydreamjob.salarycalculator.dto; + +import com.mydreamjob.salarycalculator.SalaryCalculationResult; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryCalculationResponse { + private Long historyId; + private LocalDateTime createdAt; + private SalaryCalculationResult result; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCityDetailResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCityDetailResponse.java new file mode 100644 index 0000000..f35c101 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCityDetailResponse.java @@ -0,0 +1,34 @@ +package com.mydreamjob.salarycalculator.dto; + +import com.mydreamjob.salarycalculator.SalaryDataLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryCityDetailResponse { + private String cityCode; + private String provinceCode; + private String provinceName; + private String cityName; + private String policyVersion; + private LocalDate policyEffectiveDate; + private SalaryDataLevel dataLevel; + private String dataSource; + private BigDecimal socialSecurityBaseMin; + private BigDecimal socialSecurityBaseMax; + private BigDecimal housingFundBaseMin; + private BigDecimal housingFundBaseMax; + private BigDecimal pensionRate; + private BigDecimal medicalRate; + private BigDecimal unemploymentRate; + private BigDecimal housingFundRate; + private BigDecimal seriousIllnessMonthlyFee; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCitySummaryResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCitySummaryResponse.java new file mode 100644 index 0000000..2d85502 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCitySummaryResponse.java @@ -0,0 +1,23 @@ +package com.mydreamjob.salarycalculator.dto; + +import com.mydreamjob.salarycalculator.SalaryDataLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryCitySummaryResponse { + private String cityCode; + private String provinceCode; + private String provinceName; + private String cityName; + private String policyVersion; + private LocalDate policyEffectiveDate; + private SalaryDataLevel dataLevel; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistoryDetailResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistoryDetailResponse.java new file mode 100644 index 0000000..c474f19 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistoryDetailResponse.java @@ -0,0 +1,31 @@ +package com.mydreamjob.salarycalculator.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import com.mydreamjob.salarycalculator.SalaryDataLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryHistoryDetailResponse { + private Long id; + private String cityCode; + private String cityName; + private SalaryDataLevel dataLevel; + private String policyVersion; + private String taxRulesVersion; + private Integer monthIndex; + private BigDecimal grossMonthlySalary; + private BigDecimal netSalary; + private LocalDateTime createdAt; + private JsonNode inputSnapshot; + private JsonNode policySnapshot; + private JsonNode resultSnapshot; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistorySummaryResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistorySummaryResponse.java new file mode 100644 index 0000000..87ea92a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistorySummaryResponse.java @@ -0,0 +1,25 @@ +package com.mydreamjob.salarycalculator.dto; + +import com.mydreamjob.salarycalculator.SalaryDataLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryHistorySummaryResponse { + private Long id; + private String cityCode; + private String cityName; + private SalaryDataLevel dataLevel; + private BigDecimal grossMonthlySalary; + private BigDecimal netSalary; + private Integer monthIndex; + private LocalDateTime createdAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudController.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudController.java new file mode 100644 index 0000000..01aa843 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudController.java @@ -0,0 +1,207 @@ +package com.mydreamjob.skill; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.profile.ProfileService; +import com.mydreamjob.skill.dto.SkillCloudResponse; +import com.mydreamjob.skill.dto.SkillLearningPathResponse; +import com.mydreamjob.skill.dto.SkillMatchResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 行业技能词云、个人匹配度与学习路径接口。 + */ +@Tag(name = "行业技能词云", description = "按行业、职位和技能分类查询热门技能、个人匹配度与学习路径") +@RestController +@Validated +@RequiredArgsConstructor +public class SkillCloudController { + + private final SkillCloudQueryService skillCloudQueryService; + private final SkillMatchService skillMatchService; + private final SkillLearningPathService skillLearningPathService; + private final ProfileService profileService; + + @Operation( + operationId = "getWordCloud", + summary = "查询行业技能词云", + description = "返回当前筛选范围内的全部技能,按热度降序排列;前端取前十条作为 TOP10。仅 STUDENT 可访问。" + ) + @SecurityRequirement(name = "bearerAuth") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "成功返回筛选范围内的全部技能", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "success", + value = "{\"code\":200,\"message\":\"ok\",\"data\":{\"industry\":{\"id\":1,\"code\":\"INTERNET\",\"name\":\"互联网\"},\"occupation\":{\"id\":1,\"code\":\"FRONTEND_DEVELOPER\",\"name\":\"前端开发工程师\"},\"category\":null,\"total\":1,\"items\":[{\"rank\":1,\"skillId\":26,\"skillCode\":\"REACT\",\"nameZh\":\"React\",\"nameEn\":\"React\",\"sourceType\":\"O*NET\",\"sourceCode\":\"15-1254.00|React|2.E.7.h\",\"categoryId\":103,\"categoryName\":\"前端框架\",\"heat\":4.38,\"heatSourceCode\":\"15-1254.00\",\"heatElementId\":\"2.E.7.h\",\"heatProxy\":true}]}}" + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", description = "筛选参数缺失、非正整数或节点关系非法", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", description = "未登录", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", description = "已登录但不是 STUDENT 角色", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + @GetMapping("/skills/word-cloud") + public ResponseEntity> getWordCloud( + @Parameter(description = "行业 ID,必填正整数", required = true, example = "1") + @Positive(message = "industryId must be positive") + @RequestParam Long industryId, + @Parameter(description = "职位 ID,必填正整数", required = true, example = "1") + @Positive(message = "occupationId must be positive") + @RequestParam Long occupationId, + @Parameter(description = "技能分类 ID,可选正整数;省略时返回职位下全部分类", required = false, example = "101") + @Positive(message = "categoryId must be positive") + @RequestParam(required = false) Long categoryId, + HttpServletRequest request) { + Long userId = resolveAuthenticatedStudent(); + SkillCloudResponse data = skillCloudQueryService.query(industryId, occupationId, categoryId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation( + operationId = "getSkillMatch", + summary = "查询个人技能匹配度", + description = "使用当前 STUDENT 的个人技能,与筛选结果热度 TOP10 比对并返回命中、缺失和匹配度。仅 STUDENT 可访问。" + ) + @SecurityRequirement(name = "bearerAuth") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "成功返回个人技能匹配结果", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "success", + value = "{\"code\":200,\"message\":\"ok\",\"data\":{\"matchScore\":50,\"matchedCount\":1,\"totalCount\":2,\"matchedSkills\":[{\"rank\":1,\"skillId\":26,\"skillCode\":\"REACT\",\"nameZh\":\"React\",\"nameEn\":\"React\",\"sourceType\":\"O*NET\",\"sourceCode\":\"15-1254.00|React|2.E.7.h\",\"categoryId\":103,\"categoryName\":\"前端框架\",\"heat\":4.38,\"heatSourceCode\":\"15-1254.00\",\"heatElementId\":\"2.E.7.h\",\"heatProxy\":true}],\"missingSkills\":[{\"rank\":2,\"skillId\":31,\"skillCode\":\"DOCKER\",\"nameZh\":\"Docker\",\"nameEn\":\"Docker\",\"sourceType\":\"O*NET\",\"sourceCode\":\"15-1299.08|Docker|2.E.7.h\",\"categoryId\":105,\"categoryName\":\"工程化工具\",\"heat\":4.12,\"heatSourceCode\":\"15-1299.08\",\"heatElementId\":\"2.E.7.h\",\"heatProxy\":true}]}}" + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", description = "筛选参数缺失、非正整数或节点关系非法", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", description = "未登录", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", description = "已登录但不是 STUDENT 角色", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + @GetMapping("/skills/match") + public ResponseEntity> getMatch( + @Parameter(description = "行业 ID,必填正整数", required = true, example = "1") + @Positive(message = "industryId must be positive") + @RequestParam Long industryId, + @Parameter(description = "职位 ID,必填正整数", required = true, example = "1") + @Positive(message = "occupationId must be positive") + @RequestParam Long occupationId, + @Parameter(description = "技能分类 ID,可选正整数;省略时使用职位下全部技能", required = false, example = "101") + @Positive(message = "categoryId must be positive") + @RequestParam(required = false) Long categoryId, + HttpServletRequest request) { + Long userId = resolveAuthenticatedStudent(); + SkillMatchResponse data = skillMatchService.match(userId, industryId, occupationId, categoryId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation( + operationId = "getLearningPath", + summary = "生成个人学习路径", + description = "仅针对个人 TOP10 中未命中的技能,按热度顺序返回优先级和通用学习建议。仅 STUDENT 可访问。" + ) + @SecurityRequirement(name = "bearerAuth") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "成功返回缺失技能的学习路径;没有缺口时列表为空", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "success", + value = "{\"code\":200,\"message\":\"ok\",\"data\":{\"total\":1,\"items\":[{\"priority\":1,\"skillId\":26,\"skillName\":\"React\",\"categoryId\":103,\"categoryName\":\"前端框架\",\"suggestion\":\"先理解 React 的核心概念与生命周期,再通过一个小型项目练习集成。\"}]}}" + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", description = "筛选参数缺失、非正整数或节点关系非法", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", description = "未登录", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", description = "已登录但不是 STUDENT 角色", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + @GetMapping("/skills/learning-path") + public ResponseEntity> getLearningPath( + @Parameter(description = "行业 ID,必填正整数", required = true, example = "1") + @Positive(message = "industryId must be positive") + @RequestParam Long industryId, + @Parameter(description = "职位 ID,必填正整数", required = true, example = "1") + @Positive(message = "occupationId must be positive") + @RequestParam Long occupationId, + @Parameter(description = "技能分类 ID,可选正整数;省略时使用职位下全部技能", required = false, example = "101") + @Positive(message = "categoryId must be positive") + @RequestParam(required = false) Long categoryId, + HttpServletRequest request) { + Long userId = resolveAuthenticatedStudent(); + SkillLearningPathResponse data = skillLearningPathService.generate( + userId, industryId, occupationId, categoryId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + private Long resolveAuthenticatedStudent() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null + || !authentication.isAuthenticated() + || "anonymousUser".equals(authentication.getName())) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + try { + Long userId = Long.valueOf(authentication.getName()); + profileService.assertStudentRole(userId); + return userId; + } catch (NumberFormatException exception) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudQueryService.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudQueryService.java new file mode 100644 index 0000000..f2ae2e5 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudQueryService.java @@ -0,0 +1,160 @@ +package com.mydreamjob.skill; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.skill.domain.SkillCategory; +import com.mydreamjob.skill.domain.SkillIndustry; +import com.mydreamjob.skill.domain.SkillOccupation; +import com.mydreamjob.skill.dto.SkillCloudItem; +import com.mydreamjob.skill.dto.SkillCloudNode; +import com.mydreamjob.skill.dto.SkillCloudResponse; +import com.mydreamjob.skill.dto.SkillCloudSkillProjection; +import com.mydreamjob.skill.repository.OccupationSkillRepository; +import com.mydreamjob.skill.repository.SkillCategoryRepository; +import com.mydreamjob.skill.repository.SkillIndustryRepository; +import com.mydreamjob.skill.repository.SkillOccupationRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.Comparator; +import java.util.List; +import java.util.stream.IntStream; + +@Service +@Transactional(readOnly = true) +public class SkillCloudQueryService { + + private static final Comparator SKILL_ORDER = (left, right) -> { + int heatComparison = compareHeat(left.importance(), right.importance()); + if (heatComparison != 0) { + return heatComparison; + } + + int nameComparison = compareNullable(left.nameZh(), right.nameZh()); + if (nameComparison != 0) { + return nameComparison; + } + + return compareNullable(left.skillId(), right.skillId()); + }; + + private final SkillIndustryRepository industryRepository; + private final SkillOccupationRepository occupationRepository; + private final SkillCategoryRepository categoryRepository; + private final OccupationSkillRepository occupationSkillRepository; + + public SkillCloudQueryService(SkillIndustryRepository industryRepository, + SkillOccupationRepository occupationRepository, + SkillCategoryRepository categoryRepository, + OccupationSkillRepository occupationSkillRepository) { + this.industryRepository = industryRepository; + this.occupationRepository = occupationRepository; + this.categoryRepository = categoryRepository; + this.occupationSkillRepository = occupationSkillRepository; + } + + public SkillCloudResponse query(Long industryId, Long occupationId, Long categoryId) { + long validatedIndustryId = requirePositive(industryId, "industryId"); + long validatedOccupationId = requirePositive(occupationId, "occupationId"); + Long validatedCategoryId = categoryId == null ? null : requirePositive(categoryId, "categoryId"); + + SkillIndustry industry = industryRepository.findById(validatedIndustryId) + .orElseThrow(() -> badRequest("industry not found: industryId=" + validatedIndustryId)); + SkillOccupation occupation = occupationRepository.findById(validatedOccupationId) + .orElseThrow(() -> badRequest("occupation not found: occupationId=" + validatedOccupationId)); + if (!occupation.getIndustryId().equals(validatedIndustryId)) { + throw badRequest("occupation does not belong to industry: occupationId=" + + validatedOccupationId + ", industryId=" + validatedIndustryId); + } + + SkillCategory category = null; + if (validatedCategoryId != null) { + category = categoryRepository.findById(validatedCategoryId) + .orElseThrow(() -> badRequest("category not found: categoryId=" + validatedCategoryId)); + if (!category.getOccupationId().equals(validatedOccupationId)) { + throw badRequest("category does not belong to occupation: categoryId=" + + validatedCategoryId + ", occupationId=" + validatedOccupationId); + } + } + + List projections = validatedCategoryId == null + ? occupationSkillRepository.findSkillProjectionsByOccupationId(validatedOccupationId) + : occupationSkillRepository.findSkillProjectionsByOccupationIdAndCategoryId( + validatedOccupationId, validatedCategoryId); + + List sortedProjections = projections.stream() + .sorted(SKILL_ORDER) + .toList(); + List items = IntStream.range(0, sortedProjections.size()) + .mapToObj(index -> toItem(sortedProjections.get(index), index + 1)) + .toList(); + + return new SkillCloudResponse( + toNode(industry.getId(), industry.getCode(), industry.getNameZh()), + toNode(occupation.getId(), occupation.getCode(), occupation.getNameZh()), + category == null ? null : toNode(category.getId(), category.getCode(), category.getNameZh()), + items.size(), + items + ); + } + + private SkillCloudItem toItem(SkillCloudSkillProjection projection, int rank) { + return new SkillCloudItem( + rank, + projection.skillId(), + projection.skillCode(), + projection.nameZh(), + projection.nameEn(), + projection.sourceType(), + projection.sourceCode(), + projection.categoryId(), + projection.categoryName(), + projection.importance(), + projection.importanceSourceCode(), + projection.importanceElementId(), + projection.importanceIsProxy() + ); + } + + private static int compareHeat(BigDecimal left, BigDecimal right) { + if (left == null && right == null) { + return 0; + } + if (left == null) { + return 1; + } + if (right == null) { + return -1; + } + return right.compareTo(left); + } + + private static > int compareNullable(T left, T right) { + if (left == null && right == null) { + return 0; + } + if (left == null) { + return 1; + } + if (right == null) { + return -1; + } + return left.compareTo(right); + } + + private static SkillCloudNode toNode(Long id, String code, String name) { + return new SkillCloudNode(id, code, name); + } + + private static long requirePositive(Long value, String parameterName) { + if (value == null || value <= 0) { + throw badRequest(parameterName + " must be a positive integer"); + } + return value; + } + + private static BusinessException badRequest(String detail) { + return new BusinessException(ErrorCode.BAD_REQUEST, detail); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillLearningPathService.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillLearningPathService.java new file mode 100644 index 0000000..bac87b6 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillLearningPathService.java @@ -0,0 +1,95 @@ +package com.mydreamjob.skill; + +import com.mydreamjob.skill.domain.SkillCategory; +import com.mydreamjob.skill.dto.SkillCloudItem; +import com.mydreamjob.skill.dto.SkillLearningPathItem; +import com.mydreamjob.skill.dto.SkillLearningPathResponse; +import com.mydreamjob.skill.dto.SkillMatchResponse; +import com.mydreamjob.skill.repository.SkillCategoryRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Locale; +import java.util.stream.IntStream; + +@Service +@Transactional(readOnly = true) +public class SkillLearningPathService { + + private final SkillMatchService skillMatchService; + private final SkillCategoryRepository skillCategoryRepository; + + public SkillLearningPathService(SkillMatchService skillMatchService, + SkillCategoryRepository skillCategoryRepository) { + this.skillMatchService = skillMatchService; + this.skillCategoryRepository = skillCategoryRepository; + } + + public SkillLearningPathResponse generate(Long studentUserId, + Long industryId, + Long occupationId, + Long categoryId) { + SkillMatchResponse matchResponse = skillMatchService.match( + studentUserId, industryId, occupationId, categoryId); + List missingSkills = matchResponse.missingSkills(); + List items = IntStream.range(0, missingSkills.size()) + .mapToObj(index -> toItem(missingSkills.get(index), index + 1, occupationId)) + .toList(); + + return new SkillLearningPathResponse(items.size(), items); + } + + private SkillLearningPathItem toItem(SkillCloudItem skill, int priority, Long occupationId) { + SuggestionTemplate template = resolveTemplate(skill, occupationId); + String skillName = skill.nameZh(); + return new SkillLearningPathItem( + priority, + skill.skillId(), + skillName, + skill.categoryId(), + skill.categoryName(), + template.render(skillName) + ); + } + + private SuggestionTemplate resolveTemplate(SkillCloudItem skill, Long occupationId) { + if (skill.categoryId() == null) { + return SuggestionTemplate.GENERAL; + } + + String templateCode = skillCategoryRepository + .findByIdAndOccupationId(skill.categoryId(), occupationId) + .map(SkillCategory::getSuggestionTemplateCode) + .orElse(null); + return SuggestionTemplate.fromCode(templateCode); + } + + private enum SuggestionTemplate { + PROGRAMMING_LANGUAGE("先掌握{skill}的基础语法和常见数据结构,再完成至少一个编码练习。"), + FRAMEWORK("先理解{skill}的核心概念与生命周期,再通过一个小型项目练习集成。"), + TOOL("先完成{skill}的安装与基础配置,再结合现有工程完成一次实际操作。"), + GENERAL("先了解{skill}的核心概念和常见应用场景,再通过一个简短实践巩固。"); + + private final String template; + + SuggestionTemplate(String template) { + this.template = template; + } + + String render(String skillName) { + return template.replace("{skill}", skillName); + } + + static SuggestionTemplate fromCode(String code) { + if (code == null || code.isBlank()) { + return GENERAL; + } + try { + return valueOf(code.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + return GENERAL; + } + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillMatchService.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillMatchService.java new file mode 100644 index 0000000..f4b6103 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillMatchService.java @@ -0,0 +1,152 @@ +package com.mydreamjob.skill; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.profile.StudentProfile; +import com.mydreamjob.profile.StudentProfileRepository; +import com.mydreamjob.skill.domain.SkillAlias; +import com.mydreamjob.skill.dto.SkillCloudItem; +import com.mydreamjob.skill.dto.SkillCloudResponse; +import com.mydreamjob.skill.dto.SkillMatchResponse; +import com.mydreamjob.skill.repository.SkillAliasRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Slf4j +@Service +@Transactional(readOnly = true) +public class SkillMatchService { + + private static final int TOP_LIMIT = 10; + + private final SkillCloudQueryService skillCloudQueryService; + private final StudentProfileRepository studentProfileRepository; + private final SkillAliasRepository skillAliasRepository; + private final SkillNameNormalizer skillNameNormalizer; + private final ObjectMapper objectMapper; + + public SkillMatchService(SkillCloudQueryService skillCloudQueryService, + StudentProfileRepository studentProfileRepository, + SkillAliasRepository skillAliasRepository, + SkillNameNormalizer skillNameNormalizer, + ObjectMapper objectMapper) { + this.skillCloudQueryService = skillCloudQueryService; + this.studentProfileRepository = studentProfileRepository; + this.skillAliasRepository = skillAliasRepository; + this.skillNameNormalizer = skillNameNormalizer; + this.objectMapper = objectMapper; + } + + public SkillMatchResponse match(Long studentUserId, + Long industryId, + Long occupationId, + Long categoryId) { + SkillCloudResponse cloud = skillCloudQueryService.query(industryId, occupationId, categoryId); + int actualTopSize = Math.min(TOP_LIMIT, cloud.items().size()); + List topSkills = cloud.items().subList(0, actualTopSize); + Set studentSkills = loadStudentSkills(studentUserId); + Map> aliasesBySkillId = loadAliases(topSkills); + + List matchedSkills = new ArrayList<>(); + List missingSkills = new ArrayList<>(); + for (SkillCloudItem skill : topSkills) { + if (matches(skill, studentSkills, aliasesBySkillId.getOrDefault(skill.skillId(), List.of()))) { + matchedSkills.add(skill); + } else { + missingSkills.add(skill); + } + } + + int matchScore = actualTopSize == 0 + ? 0 + : (int) Math.round(matchedSkills.size() * 100.0 / actualTopSize); + return new SkillMatchResponse( + matchScore, + matchedSkills.size(), + actualTopSize, + matchedSkills, + missingSkills + ); + } + + private Set loadStudentSkills(Long studentUserId) { + if (studentUserId == null) { + return Set.of(); + } + + StudentProfile profile = studentProfileRepository.findByUserId(studentUserId).orElse(null); + if (profile == null || profile.getSkillsJson() == null || profile.getSkillsJson().isBlank()) { + return Set.of(); + } + + try { + List parsed = objectMapper.readValue( + profile.getSkillsJson(), + new TypeReference>() {} + ); + if (parsed == null || parsed.isEmpty()) { + return Set.of(); + } + + Set normalizedSkills = new HashSet<>(); + for (String skill : parsed) { + if (skill == null) { + continue; + } + String normalized = skillNameNormalizer.normalize(skill); + if (!normalized.isBlank()) { + normalizedSkills.add(normalized); + } + } + return Set.copyOf(normalizedSkills); + } catch (Exception exception) { + log.warn("Failed to parse student skills JSON: profileId={}, errorType={}", + profile.getId(), exception.getClass().getSimpleName()); + return Set.of(); + } + } + + private Map> loadAliases(List topSkills) { + Collection skillIds = topSkills.stream() + .map(SkillCloudItem::skillId) + .filter(java.util.Objects::nonNull) + .toList(); + if (skillIds.isEmpty()) { + return Map.of(); + } + + Map> aliasesBySkillId = new HashMap<>(); + for (SkillAlias alias : skillAliasRepository.findAllBySkillIdInOrderByIdAsc(skillIds)) { + aliasesBySkillId.computeIfAbsent(alias.getSkillId(), ignored -> new ArrayList<>()).add(alias); + } + return aliasesBySkillId; + } + + private boolean matches(SkillCloudItem skill, + Set studentSkills, + List aliases) { + if (containsNormalized(studentSkills, skill.nameZh()) + || containsNormalized(studentSkills, skill.nameEn()) + || containsNormalized(studentSkills, skill.skillCode())) { + return true; + } + return aliases.stream().anyMatch(alias -> containsNormalized(studentSkills, alias.getAlias())); + } + + private boolean containsNormalized(Set studentSkills, String candidate) { + if (candidate == null) { + return false; + } + String normalized = skillNameNormalizer.normalize(candidate); + return !normalized.isBlank() && studentSkills.contains(normalized); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillNameNormalizer.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillNameNormalizer.java new file mode 100644 index 0000000..84ddf2e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillNameNormalizer.java @@ -0,0 +1,28 @@ +package com.mydreamjob.skill; + +import org.springframework.stereotype.Component; + +import java.text.Normalizer; +import java.util.Locale; +import java.util.regex.Pattern; + +@Component +public class SkillNameNormalizer { + + private static final Pattern UNICODE_HYPHENS = Pattern.compile("\\p{Pd}"); + private static final Pattern WHITESPACE = Pattern.compile("\\s+", Pattern.UNICODE_CHARACTER_CLASS); + + public String normalize(String value) { + if (value == null) { + return ""; + } + + String normalized = Normalizer.normalize(value, Normalizer.Form.NFKC) + .trim() + .toLowerCase(Locale.ROOT); + normalized = UNICODE_HYPHENS.matcher(normalized).replaceAll(" "); + normalized = normalized.replace(".", ""); + normalized = WHITESPACE.matcher(normalized).replaceAll(" "); + return normalized.trim(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/OccupationSkill.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/OccupationSkill.java new file mode 100644 index 0000000..38046c6 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/OccupationSkill.java @@ -0,0 +1,62 @@ +package com.mydreamjob.skill.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "occupation_skills") +public class OccupationSkill { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "occupation_id", nullable = false) + private Long occupationId; + + @Column(name = "skill_id", nullable = false) + private Long skillId; + + @Column(name = "category_id", nullable = false) + private Long categoryId; + + @Column(name = "importance", precision = 4, scale = 2) + private BigDecimal importance; + + @Column(name = "importance_source_code", length = 20) + private String importanceSourceCode; + + @Column(name = "importance_element_id", length = 30) + private String importanceElementId; + @Column(name = "importance_is_proxy", nullable = false) + private Boolean importanceIsProxy; + + @Column(name = "relationship_type", nullable = false, length = 20) + private String relationshipType; + + @Column(name = "source_version_id", nullable = false) + private Long sourceVersionId; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/Skill.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/Skill.java new file mode 100644 index 0000000..0d84071 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/Skill.java @@ -0,0 +1,56 @@ +package com.mydreamjob.skill.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "skills") +public class Skill { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "code", nullable = false, length = 120) + private String code; + + @Column(name = "name_zh", nullable = false, length = 180) + private String nameZh; + + @Column(name = "name_en", length = 180) + private String nameEn; + + @Column(name = "source_type", nullable = false, length = 20) + private String sourceType; + + @Column(name = "source_code", nullable = false, length = 180) + private String sourceCode; + + @Column(name = "source_version_id", nullable = false) + private Long sourceVersionId; + + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillAlias.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillAlias.java new file mode 100644 index 0000000..0d05f04 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillAlias.java @@ -0,0 +1,41 @@ +package com.mydreamjob.skill.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "skill_aliases") +public class SkillAlias { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "skill_id", nullable = false) + private Long skillId; + + @Column(name = "alias", nullable = false, length = 180) + private String alias; + + @Column(name = "normalized_alias", nullable = false, length = 180) + private String normalizedAlias; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillCategory.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillCategory.java new file mode 100644 index 0000000..dd58b08 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillCategory.java @@ -0,0 +1,50 @@ +package com.mydreamjob.skill.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "skill_categories") +public class SkillCategory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "occupation_id", nullable = false) + private Long occupationId; + + @Column(name = "code", nullable = false, length = 100) + private String code; + + @Column(name = "name_zh", nullable = false, length = 120) + private String nameZh; + + @Column(name = "suggestion_template_code", nullable = false, length = 30) + private String suggestionTemplateCode; + + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillIndustry.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillIndustry.java new file mode 100644 index 0000000..7fbd239 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillIndustry.java @@ -0,0 +1,47 @@ +package com.mydreamjob.skill.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "skill_industries") +public class SkillIndustry { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "code", nullable = false, length = 80) + private String code; + + @Column(name = "name_zh", nullable = false, length = 120) + private String nameZh; + + @Column(name = "source_code", length = 120) + private String sourceCode; + + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillOccupation.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillOccupation.java new file mode 100644 index 0000000..4a83dac --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillOccupation.java @@ -0,0 +1,56 @@ +package com.mydreamjob.skill.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "skill_occupations") +public class SkillOccupation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "industry_id", nullable = false) + private Long industryId; + + @Column(name = "code", nullable = false, length = 100) + private String code; + + @Column(name = "name_zh", nullable = false, length = 150) + private String nameZh; + + @Column(name = "name_en", length = 180) + private String nameEn; + + @Column(name = "source_code", nullable = false, length = 150) + private String sourceCode; + + @Column(name = "source_version_id", nullable = false) + private Long sourceVersionId; + + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillSourceVersion.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillSourceVersion.java new file mode 100644 index 0000000..498cebd --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillSourceVersion.java @@ -0,0 +1,50 @@ +package com.mydreamjob.skill.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "skill_source_versions") +public class SkillSourceVersion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "source_name", nullable = false, length = 100) + private String sourceName; + + @Column(name = "source_version", nullable = false, length = 150) + private String sourceVersion; + + @Column(name = "license", nullable = false, length = 255) + private String license; + + @Column(name = "attribution_url", nullable = false, length = 500) + private String attributionUrl; + + @Column(name = "imported_at", nullable = false) + private LocalDateTime importedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudItem.java new file mode 100644 index 0000000..b41a71d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudItem.java @@ -0,0 +1,20 @@ +package com.mydreamjob.skill.dto; + +import java.math.BigDecimal; + +public record SkillCloudItem( + int rank, + Long skillId, + String skillCode, + String nameZh, + String nameEn, + String sourceType, + String sourceCode, + Long categoryId, + String categoryName, + BigDecimal heat, + String heatSourceCode, + String heatElementId, + Boolean heatProxy +) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudNode.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudNode.java new file mode 100644 index 0000000..55a28fd --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudNode.java @@ -0,0 +1,8 @@ +package com.mydreamjob.skill.dto; + +public record SkillCloudNode( + Long id, + String code, + String name +) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudResponse.java new file mode 100644 index 0000000..326076e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudResponse.java @@ -0,0 +1,15 @@ +package com.mydreamjob.skill.dto; + +import java.util.List; + +public record SkillCloudResponse( + SkillCloudNode industry, + SkillCloudNode occupation, + SkillCloudNode category, + int total, + List items +) { + public SkillCloudResponse { + items = List.copyOf(items); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudSkillProjection.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudSkillProjection.java new file mode 100644 index 0000000..d2d3619 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudSkillProjection.java @@ -0,0 +1,21 @@ +package com.mydreamjob.skill.dto; + +import java.math.BigDecimal; + +public record SkillCloudSkillProjection( + Long skillId, + String skillCode, + String nameZh, + String nameEn, + String sourceType, + String sourceCode, + Long categoryId, + String categoryName, + BigDecimal importance, + String importanceSourceCode, + String importanceElementId, + Boolean importanceIsProxy, + Integer skillSortOrder, + Integer categorySortOrder +) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathItem.java new file mode 100644 index 0000000..28b6935 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathItem.java @@ -0,0 +1,11 @@ +package com.mydreamjob.skill.dto; + +public record SkillLearningPathItem( + int priority, + Long skillId, + String skillName, + Long categoryId, + String categoryName, + String suggestion +) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathResponse.java new file mode 100644 index 0000000..11ce18c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathResponse.java @@ -0,0 +1,12 @@ +package com.mydreamjob.skill.dto; + +import java.util.List; + +public record SkillLearningPathResponse( + int total, + List items +) { + public SkillLearningPathResponse { + items = List.copyOf(items); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillMatchResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillMatchResponse.java new file mode 100644 index 0000000..25953ea --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillMatchResponse.java @@ -0,0 +1,16 @@ +package com.mydreamjob.skill.dto; + +import java.util.List; + +public record SkillMatchResponse( + int matchScore, + int matchedCount, + int totalCount, + List matchedSkills, + List missingSkills +) { + public SkillMatchResponse { + matchedSkills = List.copyOf(matchedSkills); + missingSkills = List.copyOf(missingSkills); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/OccupationSkillRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/OccupationSkillRepository.java new file mode 100644 index 0000000..d3c0de1 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/OccupationSkillRepository.java @@ -0,0 +1,73 @@ +package com.mydreamjob.skill.repository; + +import com.mydreamjob.skill.domain.OccupationSkill; +import com.mydreamjob.skill.dto.SkillCloudSkillProjection; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +public interface OccupationSkillRepository extends JpaRepository { + + long countByOccupationId(Long occupationId); + + long countByOccupationIdAndCategoryId(Long occupationId, Long categoryId); + + @Transactional(readOnly = true) + @Query(""" + SELECT new com.mydreamjob.skill.dto.SkillCloudSkillProjection( + os.skillId, + s.code, + s.nameZh, + s.nameEn, + s.sourceType, + s.sourceCode, + c.id, + c.nameZh, + os.importance, + os.importanceSourceCode, + os.importanceElementId, + os.importanceIsProxy, + s.sortOrder, + c.sortOrder + ) + FROM OccupationSkill os, Skill s, SkillCategory c + WHERE os.skillId = s.id + AND os.categoryId = c.id + AND c.occupationId = os.occupationId + AND os.occupationId = :occupationId + """) + List findSkillProjectionsByOccupationId( + @Param("occupationId") Long occupationId); + + @Transactional(readOnly = true) + @Query(""" + SELECT new com.mydreamjob.skill.dto.SkillCloudSkillProjection( + os.skillId, + s.code, + s.nameZh, + s.nameEn, + s.sourceType, + s.sourceCode, + c.id, + c.nameZh, + os.importance, + os.importanceSourceCode, + os.importanceElementId, + os.importanceIsProxy, + s.sortOrder, + c.sortOrder + ) + FROM OccupationSkill os, Skill s, SkillCategory c + WHERE os.skillId = s.id + AND os.categoryId = c.id + AND c.occupationId = os.occupationId + AND os.occupationId = :occupationId + AND os.categoryId = :categoryId + """) + List findSkillProjectionsByOccupationIdAndCategoryId( + @Param("occupationId") Long occupationId, + @Param("categoryId") Long categoryId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillAliasRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillAliasRepository.java new file mode 100644 index 0000000..f674913 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillAliasRepository.java @@ -0,0 +1,15 @@ +package com.mydreamjob.skill.repository; + +import com.mydreamjob.skill.domain.SkillAlias; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface SkillAliasRepository extends JpaRepository { + + Optional findByNormalizedAlias(String normalizedAlias); + + List findAllBySkillIdInOrderByIdAsc(Collection skillIds); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillCategoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillCategoryRepository.java new file mode 100644 index 0000000..e83f478 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillCategoryRepository.java @@ -0,0 +1,13 @@ +package com.mydreamjob.skill.repository; + +import com.mydreamjob.skill.domain.SkillCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SkillCategoryRepository extends JpaRepository { + + boolean existsByIdAndOccupationId(Long id, Long occupationId); + + Optional findByIdAndOccupationId(Long id, Long occupationId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillIndustryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillIndustryRepository.java new file mode 100644 index 0000000..fc80d54 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillIndustryRepository.java @@ -0,0 +1,7 @@ +package com.mydreamjob.skill.repository; + +import com.mydreamjob.skill.domain.SkillIndustry; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SkillIndustryRepository extends JpaRepository { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillOccupationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillOccupationRepository.java new file mode 100644 index 0000000..172b22c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillOccupationRepository.java @@ -0,0 +1,13 @@ +package com.mydreamjob.skill.repository; + +import com.mydreamjob.skill.domain.SkillOccupation; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SkillOccupationRepository extends JpaRepository { + + boolean existsByIdAndIndustryId(Long id, Long industryId); + + Optional findByIdAndIndustryId(Long id, Long industryId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillRepository.java new file mode 100644 index 0000000..507f7a6 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillRepository.java @@ -0,0 +1,15 @@ +package com.mydreamjob.skill.repository; + +import com.mydreamjob.skill.domain.Skill; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface SkillRepository extends JpaRepository { + + Optional findByCode(String code); + + List findAllByIdInOrderByIdAsc(Collection ids); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillSourceVersionRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillSourceVersionRepository.java new file mode 100644 index 0000000..a22a7a4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillSourceVersionRepository.java @@ -0,0 +1,7 @@ +package com.mydreamjob.skill.repository; + +import com.mydreamjob.skill.domain.SkillSourceVersion; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SkillSourceVersionRepository extends JpaRepository { +} diff --git a/mydreamjob-server/src/main/resources/db/migration/V47__skill_cloud_taxonomy.sql b/mydreamjob-server/src/main/resources/db/migration/V47__skill_cloud_taxonomy.sql new file mode 100644 index 0000000..fa0df03 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V47__skill_cloud_taxonomy.sql @@ -0,0 +1,120 @@ +-- Flyway V47: 行业技能词云知识库表结构 +-- 来源与许可:ESCO v1.2.1 提供职位/技能/分类关系,O*NET 31.0 提供 Importance。 +-- 本迁移只建立结构,代表性种子数据由 V48 导入。 + +CREATE TABLE skill_source_versions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + source_name VARCHAR(100) NOT NULL COMMENT '来源名称:ESCO/O*NET', + source_version VARCHAR(150) NOT NULL COMMENT '固定来源版本', + license VARCHAR(255) NOT NULL COMMENT '来源许可证或复用条件', + attribution_url VARCHAR(500) NOT NULL COMMENT '官方来源或归属地址', + imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_skill_source_versions_source UNIQUE (source_name, source_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='技能词库来源版本'; + +CREATE TABLE skill_industries ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(80) NOT NULL COMMENT '项目行业稳定编码', + name_zh VARCHAR(120) NOT NULL COMMENT '中文行业名称', + source_code VARCHAR(120) COMMENT '上游或项目映射来源编码', + sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_skill_industries_code UNIQUE (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='技能词云行业'; + +CREATE TABLE skill_occupations ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + industry_id BIGINT NOT NULL COMMENT '所属行业', + code VARCHAR(100) NOT NULL COMMENT '职位稳定编码', + name_zh VARCHAR(150) NOT NULL COMMENT '中文职位名称', + name_en VARCHAR(180) COMMENT '英文职位名称', + source_code VARCHAR(150) NOT NULL COMMENT 'ESCO 职位来源 ID', + source_version_id BIGINT NOT NULL COMMENT '来源版本', + sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_skill_occupations_industry_code UNIQUE (industry_id, code), + CONSTRAINT fk_skill_occupations_industry FOREIGN KEY (industry_id) REFERENCES skill_industries (id), + CONSTRAINT fk_skill_occupations_source_version FOREIGN KEY (source_version_id) REFERENCES skill_source_versions (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='技能词云职位'; + +CREATE TABLE skill_categories ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + occupation_id BIGINT NOT NULL COMMENT '所属职位', + code VARCHAR(100) NOT NULL COMMENT '技能分类稳定编码', + name_zh VARCHAR(120) NOT NULL COMMENT '中文分类名称', + suggestion_template_code VARCHAR(30) NOT NULL COMMENT '学习建议模板编码', + sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_skill_categories_occupation_code UNIQUE (occupation_id, code), + CONSTRAINT ck_skill_categories_suggestion_template CHECK ( + suggestion_template_code IN ('PROGRAMMING_LANGUAGE', 'FRAMEWORK', 'TOOL', 'GENERAL') + ), + CONSTRAINT fk_skill_categories_occupation FOREIGN KEY (occupation_id) REFERENCES skill_occupations (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='职位技能分类'; + +CREATE TABLE skills ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(120) NOT NULL COMMENT '项目技能稳定编码', + name_zh VARCHAR(180) NOT NULL COMMENT '中文标准技能名称', + name_en VARCHAR(180) COMMENT '英文标准技能名称', + source_type VARCHAR(20) NOT NULL COMMENT '来源类型', + source_code VARCHAR(180) NOT NULL COMMENT 'ESCO/O*NET 或项目来源 ID', + source_version_id BIGINT NOT NULL COMMENT '来源版本', + sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_skills_code UNIQUE (code), + CONSTRAINT uk_skills_source UNIQUE (source_type, source_code), + CONSTRAINT ck_skills_source_type CHECK (source_type IN ('ESCO', 'O*NET', 'PROJECT')), + CONSTRAINT fk_skills_source_version FOREIGN KEY (source_version_id) REFERENCES skill_source_versions (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='标准技能'; + +CREATE TABLE skill_aliases ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + skill_id BIGINT NOT NULL COMMENT '标准技能', + alias VARCHAR(180) NOT NULL COMMENT '技能别名', + normalized_alias VARCHAR(180) NOT NULL COMMENT '归一化技能别名', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_skill_aliases_normalized_alias UNIQUE (normalized_alias), + CONSTRAINT uk_skill_aliases_skill_alias UNIQUE (skill_id, alias), + CONSTRAINT fk_skill_aliases_skill FOREIGN KEY (skill_id) REFERENCES skills (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='标准技能别名'; + +CREATE TABLE occupation_skills ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + occupation_id BIGINT NOT NULL COMMENT '职位', + skill_id BIGINT NOT NULL COMMENT '标准技能', + category_id BIGINT NOT NULL COMMENT '职位内技能分类', + importance DECIMAL(4,2) COMMENT 'O*NET Importance,0-5,允许为空', + importance_source_code VARCHAR(20) COMMENT 'O*NET SOC 来源,非空热度必填', + importance_element_id VARCHAR(30) COMMENT 'O*NET Element ID,非空热度必填', + importance_is_proxy TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为项目分类到 O*NET 元素的代理映射', + relationship_type VARCHAR(20) NOT NULL COMMENT '职位技能关系类型', + source_version_id BIGINT NOT NULL COMMENT 'Importance 来源版本', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_occupation_skills_occupation_skill UNIQUE (occupation_id, skill_id), + CONSTRAINT ck_occupation_skills_importance CHECK ( + importance IS NULL OR (importance >= 0.00 AND importance <= 5.00) + ), + CONSTRAINT ck_occupation_skills_importance_provenance CHECK ( + importance IS NULL OR (importance_source_code IS NOT NULL AND importance_element_id IS NOT NULL) + ), + CONSTRAINT ck_occupation_skills_importance_proxy CHECK ( + importance_is_proxy IN (0, 1) + ), + CONSTRAINT ck_occupation_skills_relationship_type CHECK ( + relationship_type IN ('ESSENTIAL', 'OPTIONAL', 'PROJECT') + ), + CONSTRAINT fk_occupation_skills_occupation FOREIGN KEY (occupation_id) REFERENCES skill_occupations (id), + CONSTRAINT fk_occupation_skills_skill FOREIGN KEY (skill_id) REFERENCES skills (id), + CONSTRAINT fk_occupation_skills_category FOREIGN KEY (category_id) REFERENCES skill_categories (id), + CONSTRAINT fk_occupation_skills_source_version FOREIGN KEY (source_version_id) REFERENCES skill_source_versions (id), + KEY idx_occupation_skills_occupation_category (occupation_id, category_id), + KEY idx_occupation_skills_occupation_importance (occupation_id, importance) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='职位技能关系与热度'; diff --git a/mydreamjob-server/src/main/resources/db/migration/V48__skill_cloud_seed.sql b/mydreamjob-server/src/main/resources/db/migration/V48__skill_cloud_seed.sql new file mode 100644 index 0000000..e3f2a62 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V48__skill_cloud_seed.sql @@ -0,0 +1,413 @@ +-- Flyway V48: ESCO/O*NET 代表性行业技能种子数据 +-- 本迁移只导入互联网、金融、教育三类行业及 10 个代表性职位。 +-- ESCO 负责职位、通用技能和分类主关系;O*NET software_skills 仅补充 ESCO 缺失的具体技术。 +-- O*NET software_skills.source_code 格式:SOC|Workplace Example|Element ID,均来自本地 O*NET 31.0 CSV 的真实行。 +-- +-- ESCO 职位映射: +-- FRONTEND_DEVELOPER -> c40a2919-48a9-40ea-b506-1f34f693496d +-- BACKEND_DEVELOPER -> f2b15a0e-e65a-438a-affb-29b9d50b77d1 +-- QA_ENGINEER -> 106f79e4-6264-45f1-9e7a-297435cd684b +-- DEVOPS_ENGINEER -> 使用后端软件开发者 ESCO URI 作为项目代理(ESCO 无更近 DevOps 节点) +-- FINANCIAL_DATA_ANALYST -> 8d586ee9-0ab1-4155-a3b0-2ca786c8e48c +-- RISK_MODELING_ENGINEER -> b09acdda-6680-49d1-a411-129610ee3bc0 +-- QUANTITATIVE_DEVELOPER -> 使用软件开发者 ESCO URI 作为项目代理(ESCO 无更近量化开发节点) +-- EDUCATION_PRODUCT_MANAGER -> a79170ad-0b7b-4697-864f-1748359aa10a +-- INSTRUCTIONAL_DESIGNER -> a9c30651-ccbc-4a80-900c-7880615cdf6e +-- EDUCATION_DATA_ANALYST -> d3edb8f8-3a06-47a0-8fb9-9b212c006aa2 +-- +-- O*NET Importance 代理 SOC: +-- FRONTEND_DEVELOPER -> 15-1254.00 Web Developers +-- BACKEND_DEVELOPER -> 15-1252.00 Software Developers +-- QA_ENGINEER -> 15-1253.00 Software Quality Assurance Analysts and Testers +-- DEVOPS_ENGINEER -> 15-1244.00 Network and Computer Systems Administrators +-- FINANCIAL_DATA_ANALYST -> 13-2031.00 Budget Analysts(目标金融分析师 SOC 在本地 O*NET 31.0 Importance CSV 中无数据,故使用语义较近的预算分析师代理) +-- RISK_MODELING_ENGINEER -> 13-2053.00 Insurance Underwriters(目标金融风险专家 SOC 在本地 Importance CSV 中无数据,故使用包含风险决策活动的保险承保人代理) +-- QUANTITATIVE_DEVELOPER -> 13-2099.01 Financial Quantitative Analysts +-- EDUCATION_PRODUCT_MANAGER -> 13-1082.00 Project Management Specialists(无直接教育产品经理 SOC,使用项目管理专家代理) +-- INSTRUCTIONAL_DESIGNER -> 25-9031.00 Instructional Coordinators +-- EDUCATION_DATA_ANALYST -> 15-2051.00 Data Scientists +-- +-- 分类代理规则(importance_is_proxy=1): +-- TECH=4.A.3.b.1 Working with Computers;PROCESS=4.A.2.a.2 Processing Information; +-- ANALYSIS=4.A.2.a.4 Analyzing Data or Information;COMPLIANCE=4.A.2.a.3 Evaluating Information to Determine Compliance with Standards; +-- DESIGN=4.A.2.b.2 Thinking Creatively;STRATEGY=4.A.2.b.4 Developing Objectives and Strategies; +-- MONITOR=4.A.1.a.2 Monitoring Processes, Materials, or Surroundings;ESTIMATE=4.A.1.b.3 Estimating the Quantifiable Characteristics of Products, Events, or Information; +-- COMM=4.A.4.a.2 Communicating with Supervisors, Peers, or Subordinates;EDU=2.C.6 Education and Training; +-- FINANCE=2.C.1.c Economics and Accounting;MATH=2.C.4.a Mathematics;SECURITY=2.C.8.a Public Safety and Security。 +-- 每条非空 importance 均保存上述代理 SOC、Element ID 和 importance_is_proxy=1,可由本地 O*NET CSV 逐条复核。 +-- 无法稳定映射 Importance 的技能保留关系并写 NULL,不伪造热度。 +-- +INSERT INTO skill_source_versions (id, source_name, source_version, license, attribution_url, imported_at) VALUES +(1, 'ESCO', '1.2.1 (Release v5.7.0, Last update 10/12/2025)', 'Commission Decision 2011/833/EU', 'https://esco.ec.europa.eu/en/use-esco/download', CURRENT_TIMESTAMP), +(2, 'O*NET', '31.0 Database', 'CC BY 4.0', 'https://www.onetcenter.org/database.html', CURRENT_TIMESTAMP); +INSERT INTO skill_industries (id, code, name_zh, source_code, sort_order) VALUES +(1, 'INTERNET', '互联网', 'PROJECT:INDUSTRY:INTERNET', 1), +(2, 'FINANCE', '金融', 'PROJECT:INDUSTRY:FINANCE', 2), +(3, 'EDUCATION', '教育', 'PROJECT:INDUSTRY:EDUCATION', 3); +-- DevOps 与量化开发没有更近的 ESCO 职位节点,source_code 显式复用软件开发者 URI;这是项目代理映射,不是上游原始职位关系。 +INSERT INTO skill_occupations (id, industry_id, code, name_zh, name_en, source_code, source_version_id, sort_order) VALUES +(1, 1, 'FRONTEND_DEVELOPER', '前端开发工程师', 'Frontend Developer', 'http://data.europa.eu/esco/occupation/c40a2919-48a9-40ea-b506-1f34f693496d', 1, 1), +(2, 1, 'BACKEND_DEVELOPER', '后端开发工程师', 'Backend Developer', 'http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1', 1, 2), +(3, 1, 'QA_ENGINEER', '测试工程师', 'Software QA Engineer', 'http://data.europa.eu/esco/occupation/106f79e4-6264-45f1-9e7a-297435cd684b', 1, 3), +(4, 1, 'DEVOPS_ENGINEER', 'DevOps 工程师', 'DevOps Engineer', 'http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1', 1, 4), +(5, 2, 'FINANCIAL_DATA_ANALYST', '金融数据分析师', 'Financial Data Analyst', 'http://data.europa.eu/esco/occupation/8d586ee9-0ab1-4155-a3b0-2ca786c8e48c', 1, 1), +(6, 2, 'RISK_MODELING_ENGINEER', '风控建模工程师', 'Risk Modeling Engineer', 'http://data.europa.eu/esco/occupation/b09acdda-6680-49d1-a411-129610ee3bc0', 1, 2), +(7, 2, 'QUANTITATIVE_DEVELOPER', '量化开发工程师', 'Quantitative Developer', 'http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1', 1, 3), +(8, 3, 'EDUCATION_PRODUCT_MANAGER', '教育产品经理', 'Education Product Manager', 'http://data.europa.eu/esco/occupation/a79170ad-0b7b-4697-864f-1748359aa10a', 1, 1), +(9, 3, 'INSTRUCTIONAL_DESIGNER', '教学设计师', 'Instructional Designer', 'http://data.europa.eu/esco/occupation/a9c30651-ccbc-4a80-900c-7880615cdf6e', 1, 2), +(10, 3, 'EDUCATION_DATA_ANALYST', '教育数据分析师', 'Education Data Analyst', 'http://data.europa.eu/esco/occupation/d3edb8f8-3a06-47a0-8fb9-9b212c006aa2', 1, 3); +INSERT INTO skill_categories (id, occupation_id, code, name_zh, suggestion_template_code, sort_order) VALUES +(101, 1, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1), +(102, 1, 'WEB_BASIC', 'Web 基础', 'FRAMEWORK', 2), +(103, 1, 'FRAMEWORK', '前端框架', 'FRAMEWORK', 3), +(104, 1, 'TOOLS', '工程化工具', 'TOOL', 4), +(105, 1, 'BROWSER_DEBUG', '浏览器与调试', 'TOOL', 5), +(106, 1, 'TEST', '测试', 'GENERAL', 6), +(107, 1, 'COLLABORATION', '版本控制与协作', 'GENERAL', 7), +(201, 2, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1), +(202, 2, 'FRAMEWORK', '后端框架与平台', 'FRAMEWORK', 2), +(203, 2, 'DATABASE', '数据库', 'TOOL', 3), +(204, 2, 'CACHE_MESSAGING', '缓存与消息', 'TOOL', 4), +(205, 2, 'CLOUD_DEVOPS', '云/DevOps', 'TOOL', 5), +(206, 2, 'TEST', '测试', 'GENERAL', 6), +(207, 2, 'TOOLS', '工程化工具', 'TOOL', 7), +(208, 2, 'SECURITY', '安全', 'GENERAL', 8), +(301, 3, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1), +(302, 3, 'TEST_DESIGN', '测试设计', 'GENERAL', 2), +(303, 3, 'AUTOMATION_TEST', '自动化测试框架', 'FRAMEWORK', 3), +(304, 3, 'API_DATABASE_TEST', '接口与数据库测试', 'TOOL', 4), +(305, 3, 'PERFORMANCE_TEST', '性能测试', 'TOOL', 5), +(306, 3, 'CICD_TOOLS', 'CI/CD 与工程化工具', 'TOOL', 6), +(401, 4, 'LANGUAGE_SCRIPT', '编程语言与脚本', 'PROGRAMMING_LANGUAGE', 1), +(402, 4, 'CLOUD', '云平台', 'TOOL', 2), +(403, 4, 'CONTAINER', '容器与编排', 'TOOL', 3), +(404, 4, 'CICD', 'CI/CD', 'TOOL', 4), +(405, 4, 'IAC', '配置管理与 IaC', 'TOOL', 5), +(406, 4, 'OBSERVABILITY', '可观测性', 'TOOL', 6), +(407, 4, 'SECURITY', '安全', 'GENERAL', 7), +(408, 4, 'DATABASE', '数据库', 'TOOL', 8), +(501, 5, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1), +(502, 5, 'ANALYSIS_VISUALIZATION', '数据分析与可视化', 'TOOL', 2), +(503, 5, 'DATABASE_WAREHOUSE', '数据库与数据仓库', 'TOOL', 3), +(504, 5, 'STATISTICS_MODELING', '统计与建模', 'FRAMEWORK', 4), +(505, 5, 'FINANCE_PLATFORM', '金融计算框架/平台', 'FRAMEWORK', 5), +(506, 5, 'FINANCE_DOMAIN', '金融领域知识', 'GENERAL', 6), +(507, 5, 'TOOLS', '工程化工具', 'TOOL', 7), +(601, 6, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1), +(602, 6, 'STATISTICS_MODELING', '统计与建模', 'FRAMEWORK', 2), +(603, 6, 'RISK_MODEL', '风险模型', 'GENERAL', 3), +(604, 6, 'MODEL_PLATFORM', '建模框架/平台', 'FRAMEWORK', 4), +(605, 6, 'DATA_DATABASE', '数据处理与数据库', 'TOOL', 5), +(606, 6, 'MODEL_VALIDATION', '模型验证', 'GENERAL', 6), +(607, 6, 'DEPLOY_TOOLS', '部署与工程化工具', 'TOOL', 7), +(701, 7, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1), +(702, 7, 'QUANT_PLATFORM', '量化建模框架/平台', 'FRAMEWORK', 2), +(703, 7, 'MARKET_DATA', '市场与金融数据', 'TOOL', 3), +(704, 7, 'DATABASE', '数据库', 'TOOL', 4), +(705, 7, 'PERFORMANCE', '低延迟与高性能工程', 'TOOL', 5), +(706, 7, 'TEST', '测试', 'GENERAL', 6), +(707, 7, 'CLOUD_DEVOPS', '云/DevOps', 'TOOL', 7), +(801, 8, 'EDU_DOMAIN', '教育领域知识', 'GENERAL', 1), +(802, 8, 'PRODUCT_ANALYSIS', '产品分析方法', 'TOOL', 2), +(803, 8, 'REQUIREMENTS_PROCESS', '需求与流程', 'GENERAL', 3), +(804, 8, 'DATA_ANALYSIS', '数据分析', 'TOOL', 4), +(805, 8, 'EDU_PLATFORM', '教育平台与工具', 'TOOL', 5), +(806, 8, 'COLLAB_TOOLS', '协作与工程化工具', 'GENERAL', 6), +(901, 9, 'INSTRUCTIONAL_DESIGN', '教学设计', 'FRAMEWORK', 1), +(902, 9, 'COURSE_PLATFORM', '课程与学习平台', 'TOOL', 2), +(903, 9, 'DIGITAL_CONTENT', '数字化内容工具', 'TOOL', 3), +(904, 9, 'ASSESSMENT', '评估设计', 'GENERAL', 4), +(905, 9, 'LEARNING_ANALYSIS', '学习数据分析', 'TOOL', 5), +(906, 9, 'ACCESSIBILITY', '无障碍与规范', 'GENERAL', 6), +(1001, 10, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1), +(1002, 10, 'ANALYSIS_VISUALIZATION', '数据分析与可视化', 'TOOL', 2), +(1003, 10, 'DATABASE_SQL', '数据库与 SQL', 'TOOL', 3), +(1004, 10, 'STATISTICS_MODELING', '统计与建模', 'FRAMEWORK', 4), +(1005, 10, 'EDU_DOMAIN', '教育领域知识', 'GENERAL', 5), +(1006, 10, 'REPORTING_TOOLS', '报表与工程化工具', 'TOOL', 6); +INSERT INTO skills (id, code, name_zh, name_en, source_type, source_code, source_version_id, sort_order) VALUES +(1, 'JAVA', 'Java', 'Java (computer programming)', 'ESCO', 'http://data.europa.eu/esco/skill/19a8293b-8e95-4de3-983f-77484079c389', 1, 1), +(2, 'PYTHON', 'Python', 'Python (computer programming)', 'ESCO', 'http://data.europa.eu/esco/skill/ccd0a1d9-afda-43d9-b901-96344886e14d', 1, 2), +(3, 'JAVASCRIPT', 'JavaScript', 'JavaScript', 'ESCO', 'http://data.europa.eu/esco/skill/3cd569a2-4f88-4c1e-9995-8dce8c5e51a7', 1, 3), +(4, 'TYPESCRIPT', 'TypeScript', 'TypeScript', 'ESCO', 'http://data.europa.eu/esco/skill/867137fb-ff1b-4ca3-99f3-cb6969aa2c68', 1, 4), +(5, 'SQL', 'SQL', 'SQL', 'ESCO', 'http://data.europa.eu/esco/skill/598de5b0-5b58-4ea7-8058-a4bc4d18c742', 1, 5), +(6, 'CSHARP', 'C#', 'C#', 'ESCO', 'http://data.europa.eu/esco/skill/4c016b68-4116-468c-9dc6-42710c239e4a', 1, 6), +(7, 'HTML', 'HTML', 'use markup languages', 'ESCO', 'http://data.europa.eu/esco/skill/0af062de-eb43-41e9-9b96-249e2cd22d26', 1, 7), +(8, 'CSS', 'CSS', 'CSS', 'ESCO', 'http://data.europa.eu/esco/skill/e5d1f825-60ed-4bdd-872a-e748c387f777', 1, 8), +(9, 'R', 'R', 'R', 'ESCO', 'http://data.europa.eu/esco/skill/51586df8-1c46-4b47-8583-773cb63bf00b', 1, 9), +(10, 'MYSQL', 'MySQL', 'MySQL', 'ESCO', 'http://data.europa.eu/esco/skill/4da171e5-779c-4983-a76f-91c16751e99f', 1, 10), +(11, 'POSTGRESQL', 'PostgreSQL', 'PostgreSQL', 'ESCO', 'http://data.europa.eu/esco/skill/a8d07b5a-c1a1-42c6-9d53-db9c7a2ca996', 1, 11), +(12, 'STATISTICS', '统计学', 'statistics', 'ESCO', 'http://data.europa.eu/esco/skill/7ee4c2ea-b349-4bd2-81a3-ec31475d4833', 1, 12), +(13, 'MACHINE_LEARNING', '机器学习', 'utilise machine learning', 'ESCO', 'http://data.europa.eu/esco/skill/8369c2d6-c100-4cf6-bd83-9668d8678433', 1, 13), +(14, 'FINANCIAL_ANALYSIS', '金融分析', 'financial analysis', 'ESCO', 'http://data.europa.eu/esco/skill/99571e68-801f-49af-a897-5f75996642e1', 1, 14), +(15, 'RISK_MANAGEMENT', '风险管理', 'risk management', 'ESCO', 'http://data.europa.eu/esco/skill/6eff134b-e34f-4d6e-a6e8-5e47cf2228d0', 1, 15), +(16, 'INSTRUCTIONAL_DESIGN', '教学设计', 'instructional design models', 'ESCO', 'http://data.europa.eu/esco/skill/e3145bd7-7f35-44ec-a5dc-4417c5c9ab9a', 1, 16), +(17, 'CURRICULUM_DEVELOPMENT', '课程开发', 'advise on curriculum development', 'ESCO', 'http://data.europa.eu/esco/skill/142a5319-60cb-42a2-891f-90ba92af18b1', 1, 17), +(18, 'LEARNING_MANAGEMENT_SYSTEMS', '学习管理系统', 'learning management systems', 'ESCO', 'http://data.europa.eu/esco/skill/6c80d53c-d8c9-41fe-998f-091fca208834', 1, 18), +(19, 'EDUCATIONAL_TECHNOLOGY', '教育技术', 'develop e-learning plan', 'ESCO', 'http://data.europa.eu/esco/skill/8a6394d1-f44f-4b0d-81d5-7201fc025ba8', 1, 19), +(20, 'COMMUNICATION', '沟通能力', 'communication', 'ESCO', 'http://data.europa.eu/esco/skill/15d76317-c71a-4fa2-aadc-2ecc34e627b7', 1, 20), +(21, 'CRITICAL_THINKING', '批判性思维', 'use logical reasoning', 'ESCO', 'http://data.europa.eu/esco/skill/a9e6718b-b9d8-44c2-b06e-85ceed9d144c', 1, 21), +(22, 'COLLABORATION', '团队协作', 'cooperate with colleagues', 'ESCO', 'http://data.europa.eu/esco/skill/082967d4-b94c-4305-aa13-35a6aa8c5c7c', 1, 22), +(23, 'PROJECT_MANAGEMENT', '项目管理', 'project management', 'ESCO', 'http://data.europa.eu/esco/skill/7111b95d-0ce3-441a-9d92-4c75d05c4388', 1, 23), +(24, 'AGILE', '敏捷开发', 'Agile development', 'ESCO', 'http://data.europa.eu/esco/skill/dba46f87-0831-49cd-a1c7-340a653c0221', 1, 24), +(25, 'JENKINS', 'Jenkins', 'Jenkins (tools for software configuration management)', 'ESCO', 'http://data.europa.eu/esco/skill/f47a1998-0beb-43be-9f46-380aa4d183da', 1, 25), +(26, 'REACT', 'React', 'React', 'O*NET', '15-1254.00|React|2.E.7.h', 2, 26), +(27, 'VUE_JS', 'Vue.js', 'Vue.js', 'O*NET', '15-1254.00|Vue.js|2.E.7.h', 2, 27), +(28, 'NODE_JS', 'Node.js', 'Node.js', 'O*NET', '15-1254.00|Node.js|2.E.7.h', 2, 28), +(29, 'SPRING_BOOT', 'Spring Boot', 'Spring Boot', 'O*NET', '15-1252.00|Spring Boot|2.E.10.a', 2, 29), +(30, 'DJANGO', 'Django', 'Django', 'O*NET', '15-1252.00|Django|2.E.7.h', 2, 30), +(31, 'FLASK', 'Flask', 'Flask', 'O*NET', '15-2051.00|Flask|2.E.7.b', 2, 31), +(32, 'GIT', 'Git', 'Git', 'O*NET', '15-1252.00|Git|2.E.5.c', 2, 32), +(33, 'DOCKER', 'Docker', 'Docker', 'O*NET', '15-1252.00|Docker|2.E.10.a', 2, 33), +(34, 'KUBERNETES', 'Kubernetes', 'Kubernetes', 'O*NET', '15-1252.00|Kubernetes|2.E.10.a', 2, 34), +(35, 'GRADLE', 'Gradle', 'Gradle', 'O*NET', '15-1252.00|Gradle|2.E.7.e', 2, 35), +(36, 'MONGODB', 'MongoDB', 'MongoDB', 'O*NET', '15-1252.00|MongoDB|2.E.6.d', 2, 36), +(37, 'REDIS', 'Redis', 'Redis', 'O*NET', '15-1252.00|Redis|2.E.6.d', 2, 37), +(38, 'SELENIUM', 'Selenium', 'Selenium', 'O*NET', '15-1253.00|Selenium|2.E.7.f', 2, 38), +(39, 'JUNIT', 'JUnit', 'JUnit', 'O*NET', '15-1253.00|JUnit|2.E.7.f', 2, 39), +(40, 'POSTMAN', 'Postman', 'Postman', 'O*NET', '15-1253.00|Postman|2.E.13.c', 2, 40), +(41, 'PANDAS', 'pandas', 'pandas', 'O*NET', '15-2051.00|pandas|2.E.6.f', 2, 41), +(42, 'NUMPY', 'NumPy', 'NumPy', 'O*NET', '15-2051.00|NumPy|2.E.6.f', 2, 42), +(43, 'TABLEAU', 'Tableau', 'Tableau', 'O*NET', '15-2051.00|Tableau|2.E.6.m', 2, 43), +(44, 'POWER_BI', 'Microsoft Power BI', 'Microsoft Power BI', 'O*NET', '15-2051.00|Microsoft Power BI|2.E.6.m', 2, 44), +(45, 'MICROSOFT_EXCEL', 'Microsoft Excel', 'Microsoft Excel', 'O*NET', '15-2051.00|Microsoft Excel|2.E.4.i', 2, 45), +(46, 'TENSORFLOW', 'TensorFlow', 'TensorFlow', 'O*NET', '15-2051.00|TensorFlow|2.E.9.d', 2, 46), +(47, 'PYTORCH', 'PyTorch', 'PyTorch', 'O*NET', '15-2051.00|PyTorch|2.E.6.f', 2, 47), +(48, 'SCIKIT_LEARN', 'Scikit-learn', 'Scikit-learn', 'O*NET', '15-2051.00|Scikit-learn|2.E.7.b', 2, 48), +(49, 'APACHE_SPARK', 'Apache Spark', 'Apache Spark', 'O*NET', '15-2051.00|Apache Spark|2.E.6.m', 2, 49), +(50, 'FIGMA', 'Figma', 'Figma', 'O*NET', '15-1254.00|Figma|2.E.7.d', 2, 50), +(51, 'BASH', 'Bash', 'Bash', 'O*NET', '15-1252.00|Bash|2.E.13.c', 2, 51), +(52, 'GO', 'Go', 'Go', 'O*NET', '15-1252.00|Go|2.E.7.b', 2, 52), +(53, 'CPP', 'C++', 'C++', 'O*NET', '15-1252.00|C++|2.E.7.e', 2, 53), +(54, 'GOOGLE_CHROME', 'Google Chrome', 'Google Chrome', 'O*NET', '17-2051.02|Google Chrome|2.E.10.e', 2, 54), +(55, 'DEBUGGING_SOFTWARE', '调试软件', 'Debugging software', 'O*NET', '17-2071.00|Debugging software|2.E.7.f', 2, 55), +(56, 'APACHE_KAFKA', 'Apache Kafka', 'Apache Kafka', 'O*NET', '15-1299.07|Apache Kafka|2.E.7.b', 2, 56), +(57, 'SONARQUBE', 'SonarQube', 'SonarQube', 'O*NET', '15-1252.00|SonarQube|2.E.7.f', 2, 57), +(58, 'CHEF', 'Chef', 'Chef', 'O*NET', '15-1255.00|Chef|2.E.7.a', 2, 58), +(59, 'GITLAB', 'GitLab', 'GitLab', 'O*NET', '15-1299.08|GitLab|2.E.10.a', 2, 59), +(60, 'NAGIOS', 'Nagios', 'Nagios', 'O*NET', '11-3021.00|Nagios|2.E.11.a', 2, 60), +(61, 'NMAP', 'Nmap', 'Nmap', 'O*NET', '13-1199.07|Nmap|2.E.14.d', 2, 61), +(62, 'BLOOMBERG_PROFESSIONAL', 'Bloomberg Professional', 'Bloomberg Professional', 'O*NET', '41-3031.00|Bloomberg Professional|2.E.2.d', 2, 62), +(63, 'MOODLE', 'Moodle', 'Moodle', 'O*NET', '25-2022.00|Moodle|2.E.8.b', 2, 63), +(64, 'ARTICULATE_STORYLINE', 'Articulate Storyline', 'Articulate Storyline', 'O*NET', '25-9031.00|Articulate Storyline|2.E.8.b', 2, 64), +(65, 'MICROSOFT_POWERPOINT', 'Microsoft PowerPoint', 'Microsoft PowerPoint', 'O*NET', '27-4012.00|Microsoft PowerPoint|2.E.4.f', 2, 65), +(66, 'AXURE_RP', 'Axure RP', 'Axure RP', 'O*NET', '15-1255.00|Axure RP|2.E.4.g', 2, 66), +(67, 'ADOBE_PHOTOSHOP', 'Adobe Photoshop', 'Adobe Photoshop', 'O*NET', '19-3092.00|Adobe Photoshop|2.E.4.b', 2, 67), +(68, 'APACHE_JMETER', 'Apache JMeter', 'Apache JMeter', 'O*NET', '15-1253.00|Apache JMeter|2.E.7.e', 2, 68), +(69, 'ADOBE_ACROBAT', 'Adobe Acrobat', 'Adobe Acrobat', 'O*NET', '11-1011.00|Adobe Acrobat|2.E.5.b', 2, 69), +(70, 'MICROSOFT_AZURE', 'Microsoft Azure', 'Microsoft Azure software', 'O*NET', '41-4011.00|Microsoft Azure software|2.E.7.b', 2, 70), +(71, 'AWS', 'AWS', 'Amazon Web Services AWS software', 'O*NET', '11-1021.00|Amazon Web Services AWS software|2.E.6.f', 2, 71), +(72, 'WIRESHARK', 'Wireshark', 'Wireshark', 'O*NET', '15-1241.01|Wireshark|2.E.11.a', 2, 72); +INSERT INTO skill_aliases (id, skill_id, alias, normalized_alias) VALUES +(1, 1, 'Java (computer programming)', 'java (computer programming)'), +(2, 2, 'Python (computer programming)', 'python (computer programming)'), +(3, 3, 'JavaScript', 'javascript'), +(4, 4, 'TypeScript', 'typescript'), +(5, 5, 'SQL', 'sql'), +(6, 6, 'C#', 'c#'), +(7, 7, 'HTML', 'html'), +(8, 8, 'CSS', 'css'), +(9, 9, 'R', 'r'), +(10, 10, 'MySQL', 'mysql'), +(11, 11, 'PostgreSQL', 'postgresql'), +(12, 12, 'statistics', 'statistics'), +(13, 13, 'machine learning', 'machine learning'), +(14, 14, 'financial analysis', 'financial analysis'), +(15, 15, 'risk management', 'risk management'), +(16, 16, 'instructional design', 'instructional design'), +(17, 17, 'curriculum development', 'curriculum development'), +(18, 18, 'learning management systems', 'learning management systems'), +(19, 19, 'educational technology', 'educational technology'), +(20, 20, 'communication', 'communication'), +(21, 21, 'critical thinking', 'critical thinking'), +(22, 22, 'collaboration', 'collaboration'), +(23, 23, 'project management', 'project management'), +(24, 24, 'agile', 'agile'), +(25, 25, 'Jenkins (tools for software configuration management)', 'jenkins (tools for software configuration management)'), +(26, 26, 'React', 'react'), +(27, 27, 'Vue.js', 'vuejs'), +(28, 28, 'Node.js', 'nodejs'), +(29, 29, 'Spring Boot', 'spring boot'), +(30, 30, 'Django', 'django'), +(31, 31, 'Flask', 'flask'), +(32, 32, 'Git', 'git'), +(33, 33, 'Docker', 'docker'), +(34, 34, 'Kubernetes', 'kubernetes'), +(35, 35, 'Gradle', 'gradle'), +(36, 36, 'MongoDB', 'mongodb'), +(37, 37, 'Redis', 'redis'), +(38, 38, 'Selenium', 'selenium'), +(39, 39, 'JUnit', 'junit'), +(40, 40, 'Postman', 'postman'), +(41, 41, 'pandas', 'pandas'), +(42, 42, 'NumPy', 'numpy'), +(43, 43, 'Tableau', 'tableau'), +(44, 44, 'Microsoft Power BI', 'microsoft power bi'), +(45, 45, 'Microsoft Excel', 'microsoft excel'), +(46, 46, 'TensorFlow', 'tensorflow'), +(47, 47, 'PyTorch', 'pytorch'), +(48, 48, 'scikit-learn', 'scikit learn'), +(49, 49, 'Apache Spark', 'apache spark'), +(50, 50, 'Figma', 'figma'), +(51, 51, 'Bash', 'bash'), +(52, 52, 'Go', 'go'), +(53, 53, 'C++', 'c++'), +(54, 54, 'Google Chrome', 'google chrome'), +(55, 55, 'debugging software', 'debugging software'), +(56, 56, 'Apache Kafka', 'apache kafka'), +(57, 57, 'SonarQube', 'sonarqube'), +(58, 58, 'Chef', 'chef'), +(59, 59, 'GitLab', 'gitlab'), +(60, 60, 'Nagios', 'nagios'), +(61, 61, 'Nmap', 'nmap'), +(62, 62, 'Bloomberg Professional', 'bloomberg professional'), +(63, 63, 'Moodle', 'moodle'), +(64, 64, 'Articulate Storyline', 'articulate storyline'), +(65, 65, 'Microsoft PowerPoint', 'microsoft powerpoint'), +(66, 66, 'Axure RP', 'axure rp'), +(67, 67, 'Adobe Photoshop', 'adobe photoshop'), +(68, 68, 'Apache JMeter', 'apache jmeter'), +(69, 69, 'Adobe Acrobat', 'adobe acrobat'), +(70, 70, 'Microsoft Azure software', 'microsoft azure software'), +(71, 71, 'Amazon Web Services AWS software', 'amazon web services aws software'), +(72, 72, 'Wireshark', 'wireshark'); +INSERT INTO occupation_skills (id, occupation_id, skill_id, category_id, importance, importance_source_code, importance_element_id, importance_is_proxy, relationship_type, source_version_id) VALUES +(1, 1, 3, 101, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(2, 1, 4, 101, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(3, 1, 7, 102, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(4, 1, 8, 102, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(5, 1, 26, 103, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(6, 1, 27, 103, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(7, 1, 28, 104, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(8, 1, 35, 104, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(9, 1, 54, 105, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(10, 1, 55, 105, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(11, 1, 38, 106, 3.33, '15-1254.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), +(12, 1, 39, 106, 3.33, '15-1254.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), +(13, 1, 32, 107, 3.88, '15-1254.00', '4.A.4.a.2', 1, 'ESSENTIAL', 2), +(14, 1, 24, 107, 3.88, '15-1254.00', '4.A.4.a.2', 1, 'ESSENTIAL', 2), +(15, 2, 1, 201, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(16, 2, 2, 201, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(17, 2, 29, 202, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(18, 2, 30, 202, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(19, 2, 10, 203, 4.38, '15-1252.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), +(20, 2, 11, 203, 4.38, '15-1252.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), +(21, 2, 37, 204, 4.38, '15-1252.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), +(22, 2, 56, 204, 4.38, '15-1252.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), +(23, 2, 33, 205, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(24, 2, 34, 205, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(25, 2, 39, 206, 3.94, '15-1252.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), +(26, 2, 40, 206, 3.94, '15-1252.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), +(27, 2, 32, 207, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(28, 2, 35, 207, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(29, 2, 61, 208, 2.57, '15-1252.00', '2.C.8.a', 1, 'ESSENTIAL', 2), +(30, 2, 57, 208, 2.57, '15-1252.00', '2.C.8.a', 1, 'ESSENTIAL', 2), +(31, 3, 1, 301, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(32, 3, 2, 301, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(33, 3, 40, 302, 4.24, '15-1253.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), +(34, 3, 21, 302, 4.24, '15-1253.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), +(35, 3, 38, 303, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(36, 3, 39, 303, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(37, 3, 5, 304, 4.38, '15-1253.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), +(38, 3, 10, 304, 4.38, '15-1253.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), +(39, 3, 68, 305, 2.94, '15-1253.00', '4.A.1.b.3', 1, 'ESSENTIAL', 2), +(40, 3, 51, 305, 2.94, '15-1253.00', '4.A.1.b.3', 1, 'ESSENTIAL', 2), +(41, 3, 25, 306, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(42, 3, 59, 306, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(43, 4, 2, 401, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(44, 4, 51, 401, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(45, 4, 71, 402, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(46, 4, 70, 402, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(47, 4, 33, 403, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(48, 4, 34, 403, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(49, 4, 25, 404, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(50, 4, 59, 404, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(51, 4, 58, 405, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(52, 4, 32, 405, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(53, 4, 60, 406, 3.60, '15-1244.00', '4.A.1.a.2', 1, 'ESSENTIAL', 2), +(54, 4, 72, 406, 3.60, '15-1244.00', '4.A.1.a.2', 1, 'ESSENTIAL', 2), +(55, 4, 61, 407, 2.34, '15-1244.00', '2.C.8.a', 1, 'ESSENTIAL', 2), +(56, 4, 57, 407, 2.34, '15-1244.00', '2.C.8.a', 1, 'ESSENTIAL', 2), +(57, 4, 10, 408, 4.03, '15-1244.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), +(58, 4, 11, 408, 4.03, '15-1244.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), +(59, 5, 2, 501, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(60, 5, 9, 501, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(61, 5, 41, 502, 4.01, '13-2031.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), +(62, 5, 43, 502, 4.01, '13-2031.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), +(63, 5, 5, 503, 4.44, '13-2031.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), +(64, 5, 11, 503, 4.44, '13-2031.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), +(65, 5, 12, 504, 4.03, '13-2031.00', '2.C.4.a', 1, 'ESSENTIAL', 2), +(66, 5, 13, 504, 4.03, '13-2031.00', '2.C.4.a', 1, 'ESSENTIAL', 2), +(67, 5, 62, 505, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(68, 5, 45, 505, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(69, 5, 14, 506, 4.42, '13-2031.00', '2.C.1.c', 1, 'ESSENTIAL', 2), +(70, 5, 15, 506, 4.42, '13-2031.00', '2.C.1.c', 1, 'ESSENTIAL', 2), +(71, 5, 32, 507, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(72, 5, 44, 507, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(73, 6, 2, 601, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(74, 6, 9, 601, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(75, 6, 12, 602, 3.27, '13-2053.00', '2.C.4.a', 1, 'ESSENTIAL', 2), +(76, 6, 13, 602, 3.27, '13-2053.00', '2.C.4.a', 1, 'ESSENTIAL', 2), +(77, 6, 15, 603, 4.43, '13-2053.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), +(78, 6, 21, 603, 4.43, '13-2053.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), +(79, 6, 46, 604, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(80, 6, 48, 604, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(81, 6, 5, 605, 4.13, '13-2053.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), +(82, 6, 49, 605, 4.13, '13-2053.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), +(83, 6, 39, 606, 3.90, '13-2053.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), +(84, 6, 57, 606, 3.90, '13-2053.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), +(85, 6, 33, 607, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(86, 6, 32, 607, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(87, 7, 2, 701, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(88, 7, 53, 701, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(89, 7, 52, 701, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(90, 7, 42, 702, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(91, 7, 48, 702, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(92, 7, 62, 703, 4.20, '13-2099.01', '2.C.1.c', 1, 'ESSENTIAL', 2), +(93, 7, 14, 703, 4.20, '13-2099.01', '2.C.1.c', 1, 'ESSENTIAL', 2), +(94, 7, 11, 704, 4.50, '13-2099.01', '4.A.2.a.2', 1, 'ESSENTIAL', 2), +(95, 7, 5, 704, 4.50, '13-2099.01', '4.A.2.a.2', 1, 'ESSENTIAL', 2), +(96, 7, 49, 705, 3.53, '13-2099.01', '4.A.1.b.3', 1, 'ESSENTIAL', 2), +(97, 7, 56, 705, 3.53, '13-2099.01', '4.A.1.b.3', 1, 'ESSENTIAL', 2), +(98, 7, 39, 706, 3.05, '13-2099.01', '4.A.2.a.3', 1, 'ESSENTIAL', 2), +(99, 7, 68, 706, 3.05, '13-2099.01', '4.A.2.a.3', 1, 'ESSENTIAL', 2), +(100, 7, 33, 707, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(101, 7, 34, 707, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(102, 8, 16, 801, 2.52, '13-1082.00', '2.C.6', 1, 'ESSENTIAL', 2), +(103, 8, 17, 801, 2.52, '13-1082.00', '2.C.6', 1, 'ESSENTIAL', 2), +(104, 8, 66, 802, 3.94, '13-1082.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), +(105, 8, 44, 802, 3.94, '13-1082.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), +(106, 8, 23, 803, 3.73, '13-1082.00', '4.A.2.b.4', 1, 'ESSENTIAL', 2), +(107, 8, 24, 803, 3.73, '13-1082.00', '4.A.2.b.4', 1, 'ESSENTIAL', 2), +(108, 8, 43, 804, 3.94, '13-1082.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), +(109, 8, 2, 804, 3.94, '13-1082.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), +(110, 8, 18, 805, 4.45, '13-1082.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(111, 8, 63, 805, 4.45, '13-1082.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(112, 8, 32, 806, 4.73, '13-1082.00', '4.A.4.a.2', 1, 'ESSENTIAL', 2), +(113, 8, 45, 806, 4.73, '13-1082.00', '4.A.4.a.2', 1, 'ESSENTIAL', 2), +(114, 9, 16, 901, 4.78, '25-9031.00', '2.C.6', 1, 'ESSENTIAL', 2), +(115, 9, 17, 901, 4.78, '25-9031.00', '2.C.6', 1, 'ESSENTIAL', 2), +(116, 9, 18, 902, 4.45, '25-9031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(117, 9, 63, 902, 4.45, '25-9031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(118, 9, 64, 903, 3.72, '25-9031.00', '4.A.2.b.2', 1, 'ESSENTIAL', 2), +(119, 9, 50, 903, 3.72, '25-9031.00', '4.A.2.b.2', 1, 'ESSENTIAL', 2), +(120, 9, 19, 904, 3.83, '25-9031.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), +(121, 9, 21, 904, 3.83, '25-9031.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), +(122, 9, 12, 905, 4.19, '25-9031.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), +(123, 9, 45, 905, 4.19, '25-9031.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), +(124, 9, 69, 906, 3.83, '25-9031.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), +(125, 9, 20, 906, 3.83, '25-9031.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), +(126, 10, 2, 1001, 4.75, '15-2051.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(127, 10, 9, 1001, 4.75, '15-2051.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(128, 10, 43, 1002, 4.83, '15-2051.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), +(129, 10, 44, 1002, 4.83, '15-2051.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), +(130, 10, 5, 1003, 4.71, '15-2051.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), +(131, 10, 11, 1003, 4.71, '15-2051.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), +(132, 10, 12, 1004, 4.52, '15-2051.00', '2.C.4.a', 1, 'ESSENTIAL', 2), +(133, 10, 13, 1004, 4.52, '15-2051.00', '2.C.4.a', 1, 'ESSENTIAL', 2), +(134, 10, 17, 1005, 2.28, '15-2051.00', '2.C.6', 1, 'ESSENTIAL', 2), +(135, 10, 19, 1005, 2.28, '15-2051.00', '2.C.6', 1, 'ESSENTIAL', 2), +(136, 10, 45, 1006, 4.75, '15-2051.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(137, 10, 65, 1006, 4.75, '15-2051.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), +(138, 1, 50, 104, NULL, NULL, NULL, 0, 'OPTIONAL', 2), +(139, 9, 67, 903, NULL, NULL, NULL, 0, 'OPTIONAL', 2); diff --git a/mydreamjob-server/src/main/resources/db/migration/V49__skill_cloud_boolean_type.sql b/mydreamjob-server/src/main/resources/db/migration/V49__skill_cloud_boolean_type.sql new file mode 100644 index 0000000..f9b42fc --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V49__skill_cloud_boolean_type.sql @@ -0,0 +1,12 @@ +-- Flyway V49: 将技能代理标记规范化为跨方言 BOOLEAN。 +-- 不改写 V47/V48,确保已应用迁移的 Flyway checksum 保持有效。 + +ALTER TABLE occupation_skills + DROP CONSTRAINT ck_occupation_skills_importance_proxy; + +ALTER TABLE occupation_skills + MODIFY COLUMN importance_is_proxy BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE occupation_skills + ADD CONSTRAINT ck_occupation_skills_importance_proxy + CHECK (importance_is_proxy IN (FALSE, TRUE)); diff --git a/mydreamjob-server/src/main/resources/db/migration/V50__onboarding_checklist_and_company_knowledge.sql b/mydreamjob-server/src/main/resources/db/migration/V50__onboarding_checklist_and_company_knowledge.sql new file mode 100644 index 0000000..7ef47b5 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V50__onboarding_checklist_and_company_knowledge.sql @@ -0,0 +1,57 @@ +-- Flyway V50: 入职清单与企业知识表结构 +-- 本迁移只建立三张业务表;通用清单内容由 V51 导入。 + +CREATE TABLE onboarding_checklist_items ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + scope_type VARCHAR(20) NOT NULL COMMENT 'STANDARD 或 COMPANY', + scope_id BIGINT NOT NULL COMMENT '通用项为 0,企业项为 company_id', + company_id BIGINT NULL COMMENT '企业项所属企业;通用项为空', + item_key VARCHAR(80) NOT NULL COMMENT '清单项稳定业务键', + title VARCHAR(120) NOT NULL COMMENT '标题', + description VARCHAR(1000) NULL COMMENT '说明', + category VARCHAR(30) NOT NULL COMMENT 'DOCUMENT/ADMIN/EQUIPMENT/TRAINING/LOGISTICS/OTHER', + sort_order INT NOT NULL DEFAULT 1000 COMMENT '排序值', + required BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否必填', + active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否生效', + idempotency_key VARCHAR(64) NULL COMMENT '企业创建幂等键', + request_hash CHAR(64) NULL COMMENT '规范化创建请求 SHA-256', + version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT ck_onboarding_checklist_scope_type CHECK (scope_type IN ('STANDARD', 'COMPANY')), + CONSTRAINT ck_onboarding_checklist_category CHECK ( + category IN ('DOCUMENT', 'ADMIN', 'EQUIPMENT', 'TRAINING', 'LOGISTICS', 'OTHER') + ), + CONSTRAINT ck_onboarding_checklist_sort_order CHECK (sort_order BETWEEN 0 AND 9999), + CONSTRAINT uk_onboarding_checklist_scope_item UNIQUE (scope_type, scope_id, item_key), + CONSTRAINT uk_onboarding_checklist_company_idempotency UNIQUE (company_id, idempotency_key), + CONSTRAINT fk_onboarding_checklist_company FOREIGN KEY (company_id) REFERENCES companies (id), + INDEX idx_onboarding_checklist_scope_active_sort (scope_type, scope_id, active, sort_order, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入职清单项'; + +CREATE TABLE student_onboarding_progress ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + student_user_id BIGINT NOT NULL COMMENT '学生用户', + item_id BIGINT NOT NULL COMMENT '清单项', + completed BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否完成', + completed_at DATETIME NULL COMMENT '完成时间;取消完成时为空', + version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_student_onboarding_progress_student_item UNIQUE (student_user_id, item_id), + CONSTRAINT fk_student_onboarding_progress_student FOREIGN KEY (student_user_id) REFERENCES users (id), + CONSTRAINT fk_student_onboarding_progress_item FOREIGN KEY (item_id) REFERENCES onboarding_checklist_items (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='学生入职清单完成状态'; + +CREATE TABLE company_knowledge ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + company_id BIGINT NOT NULL COMMENT '企业', + culture VARCHAR(2000) NULL COMMENT '企业文化', + work_policies VARCHAR(4000) NULL COMMENT '工作制度', + onboarding_guide VARCHAR(4000) NULL COMMENT '入职指南', + version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_company_knowledge_company UNIQUE (company_id), + CONSTRAINT fk_company_knowledge_company FOREIGN KEY (company_id) REFERENCES companies (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='企业知识介绍'; diff --git a/mydreamjob-server/src/main/resources/db/migration/V51__onboarding_standard_checklist_seed.sql b/mydreamjob-server/src/main/resources/db/migration/V51__onboarding_standard_checklist_seed.sql new file mode 100644 index 0000000..7d64b3d --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V51__onboarding_standard_checklist_seed.sql @@ -0,0 +1,31 @@ +-- Flyway V51: 通用入职准备清单种子 +-- 通用项使用 scope_id=0、company_id=NULL,内容、分类、顺序和必填标记均为固定值。 + +INSERT INTO onboarding_checklist_items + (scope_type, scope_id, company_id, item_key, title, description, category, sort_order, + required, active, idempotency_key, request_hash, version) +VALUES + ('STANDARD', 0, NULL, 'standard-id-card', '准备身份证件', + '准备身份证原件或有效身份证明,用于入职登记。', 'DOCUMENT', 100, TRUE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-education-proof', '准备学历学位证明', + '按公司要求准备学历、学位证书或学信网证明。', 'DOCUMENT', 110, TRUE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-id-photo', '准备证件照', + '准备符合公司要求的近期证件照。', 'DOCUMENT', 120, FALSE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-bank-account', '提供工资卡信息', + '按公司要求提供本人工资卡账户信息。', 'ADMIN', 200, TRUE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-emergency-contact', '填写紧急联系人', + '填写必要的紧急联系人和联系方式。', 'ADMIN', 210, TRUE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-background-check', '完成背景核验', + '按公司通知配合完成背景信息核验。', 'ADMIN', 220, FALSE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-medical-check', '确认入职体检要求', + '确认岗位是否需要体检及指定机构要求。', 'ADMIN', 230, FALSE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-equipment-request', '确认办公设备需求', + '提前确认工位、电脑或其他办公设备需求。', 'EQUIPMENT', 300, FALSE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-report-location', '确认报到地址和时间', + '与 HR 确认入职日期、报到地址和接待人。', 'LOGISTICS', 400, TRUE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-commute-plan', '规划通勤路线', + '提前规划到岗路线和交通时间。', 'LOGISTICS', 410, FALSE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-compliance-training', '完成合规与保密培训', + '完成公司要求的合规、安全和保密培训。', 'TRAINING', 500, TRUE, TRUE, NULL, NULL, 0), + ('STANDARD', 0, NULL, 'standard-onboarding-guide', '阅读入职指南', + '阅读公司入职指南及相关准备说明。', 'TRAINING', 510, FALSE, TRUE, NULL, NULL, 0); diff --git a/mydreamjob-server/src/main/resources/db/migration/V52__salary_calculator_schema.sql b/mydreamjob-server/src/main/resources/db/migration/V52__salary_calculator_schema.sql new file mode 100644 index 0000000..4ef0723 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V52__salary_calculator_schema.sql @@ -0,0 +1,69 @@ +-- Flyway V52: 薪资计算器城市政策与历史快照表结构 +-- V52 只创建结构;目标城市政策由 V53 导入。 + +CREATE TABLE salary_city_policy ( + city_code VARCHAR(6) NOT NULL PRIMARY KEY COMMENT '六位行政区划编码', + province_code VARCHAR(6) NOT NULL COMMENT '六位省级行政区划编码', + province_name VARCHAR(40) NOT NULL COMMENT '省份名称', + city_name VARCHAR(80) NOT NULL COMMENT '城市名称', + policy_version VARCHAR(64) NOT NULL COMMENT '城市政策版本', + policy_effective_date DATE NOT NULL COMMENT '政策实际生效日期', + data_source VARCHAR(500) NULL COMMENT '可选数据来源说明', + social_security_base_min DECIMAL(12,2) NOT NULL COMMENT '社保缴费基数下限', + social_security_base_max DECIMAL(12,2) NOT NULL COMMENT '社保缴费基数上限', + housing_fund_base_min DECIMAL(12,2) NOT NULL COMMENT '公积金缴存基数下限', + housing_fund_base_max DECIMAL(12,2) NOT NULL COMMENT '公积金缴存基数上限', + pension_rate DECIMAL(7,6) NOT NULL COMMENT '养老个人比例', + medical_rate DECIMAL(7,6) NOT NULL COMMENT '医疗个人比例', + unemployment_rate DECIMAL(7,6) NOT NULL COMMENT '失业个人比例', + housing_fund_rate DECIMAL(7,6) NOT NULL COMMENT '公积金个人比例', + serious_illness_monthly_fee DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '大病固定费/月', + data_level VARCHAR(30) NOT NULL DEFAULT 'CITY' COMMENT 'CITY 或 PROVINCE_FALLBACK', + enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT ck_scp_ss_base CHECK ( + social_security_base_min >= 0 + AND social_security_base_max >= social_security_base_min + ), + CONSTRAINT ck_scp_fund_base CHECK ( + housing_fund_base_min >= 0 + AND housing_fund_base_max >= housing_fund_base_min + ), + CONSTRAINT ck_scp_pension_rate CHECK (pension_rate >= 0 AND pension_rate <= 0.20), + CONSTRAINT ck_scp_medical_rate CHECK (medical_rate >= 0 AND medical_rate <= 0.20), + CONSTRAINT ck_scp_unemployment_rate CHECK (unemployment_rate >= 0 AND unemployment_rate <= 0.10), + CONSTRAINT ck_scp_fund_rate CHECK (housing_fund_rate >= 0 AND housing_fund_rate <= 0.12), + CONSTRAINT ck_scp_illness_fee CHECK ( + serious_illness_monthly_fee >= 0 AND serious_illness_monthly_fee <= 10000.00 + ), + CONSTRAINT ck_scp_data_level CHECK (data_level IN ('CITY', 'PROVINCE_FALLBACK')), + INDEX idx_scp_enabled_order (enabled, province_code, city_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='薪资计算城市政策'; + +CREATE TABLE salary_calculation_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + student_user_id BIGINT NOT NULL COMMENT '所属学生用户', + city_code VARCHAR(6) NOT NULL COMMENT '城市编码快照', + city_name VARCHAR(80) NOT NULL COMMENT '城市名称快照', + data_level VARCHAR(30) NOT NULL COMMENT 'CITY 或 PROVINCE_FALLBACK', + policy_version VARCHAR(64) NOT NULL COMMENT '城市政策版本', + tax_rules_version VARCHAR(64) NOT NULL COMMENT '全国个税规则版本', + month_index INT NOT NULL COMMENT '累计计税月份', + gross_monthly_salary DECIMAL(12,2) NOT NULL COMMENT '税前月薪', + net_salary DECIMAL(12,2) NOT NULL COMMENT '到手工资', + idempotency_key VARCHAR(64) NULL COMMENT '可选幂等键', + request_hash CHAR(64) NULL COMMENT '规范化请求指纹', + input_snapshot_json TEXT NOT NULL COMMENT '输入快照 JSON', + policy_snapshot_json TEXT NOT NULL COMMENT '政策与最终参数快照 JSON', + result_snapshot_json TEXT NOT NULL COMMENT '完整结果快照 JSON', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT ck_sch_month_index CHECK (month_index BETWEEN 1 AND 12), + CONSTRAINT ck_sch_gross CHECK (gross_monthly_salary > 0 AND gross_monthly_salary <= 10000000.00), + CONSTRAINT ck_sch_net CHECK (net_salary >= 0), + CONSTRAINT uk_sch_student_idempotency UNIQUE (student_user_id, idempotency_key), + CONSTRAINT fk_sch_student FOREIGN KEY (student_user_id) REFERENCES users (id), + CONSTRAINT fk_sch_city FOREIGN KEY (city_code) REFERENCES salary_city_policy (city_code), + INDEX idx_sch_student_created (student_user_id, created_at, id), + INDEX idx_sch_student_city (student_user_id, city_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='薪资计算历史快照'; diff --git a/mydreamjob-server/src/main/resources/db/migration/V53__salary_city_policy_seed.sql b/mydreamjob-server/src/main/resources/db/migration/V53__salary_city_policy_seed.sql new file mode 100644 index 0000000..a300c18 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V53__salary_city_policy_seed.sql @@ -0,0 +1,368 @@ +-- Flyway V53: 分层城市政策种子 +-- CITY 为已核验城市级参数;PROVINCE_FALLBACK 为省级公开基线回退,待城市级核验。 +INSERT INTO salary_city_policy ( + city_code, province_code, province_name, city_name, policy_version, policy_effective_date, + data_source, social_security_base_min, social_security_base_max, housing_fund_base_min, + housing_fund_base_max, pension_rate, medical_rate, unemployment_rate, housing_fund_rate, + serious_illness_monthly_fee, data_level, enabled +) VALUES + ('110000', '110000', '北京市', '北京市', '2026-09-20-v1', '2026-07-01', '公开城市参数整理(待发布前复核)', 7270, 36348, 2540, 36348, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('120000', '120000', '天津市', '天津市', '2026-09-20-v1', '2026-07-01', '公开城市参数整理(待发布前复核)', 5180, 25902, 2510, 28917, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('130100', '130000', '河北省', '石家庄市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('130200', '130000', '河北省', '唐山市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('130300', '130000', '河北省', '秦皇岛市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('130400', '130000', '河北省', '邯郸市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('130500', '130000', '河北省', '邢台市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('130600', '130000', '河北省', '保定市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('130700', '130000', '河北省', '张家口市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('130800', '130000', '河北省', '承德市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('130900', '130000', '河北省', '沧州市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('131000', '130000', '河北省', '廊坊市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('131100', '130000', '河北省', '衡水市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140100', '140000', '山西省', '太原市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140200', '140000', '山西省', '大同市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140300', '140000', '山西省', '阳泉市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140400', '140000', '山西省', '长治市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140500', '140000', '山西省', '晋城市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140600', '140000', '山西省', '朔州市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140700', '140000', '山西省', '晋中市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140800', '140000', '山西省', '运城市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140900', '140000', '山西省', '忻州市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('141000', '140000', '山西省', '临汾市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('141100', '140000', '山西省', '吕梁市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150100', '150000', '内蒙古自治区', '呼和浩特市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150200', '150000', '内蒙古自治区', '包头市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150300', '150000', '内蒙古自治区', '乌海市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150400', '150000', '内蒙古自治区', '赤峰市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150500', '150000', '内蒙古自治区', '通辽市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150600', '150000', '内蒙古自治区', '鄂尔多斯市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150700', '150000', '内蒙古自治区', '呼伦贝尔市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150800', '150000', '内蒙古自治区', '巴彦淖尔市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150900', '150000', '内蒙古自治区', '乌兰察布市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('152200', '150000', '内蒙古自治区', '兴安盟', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('152500', '150000', '内蒙古自治区', '锡林郭勒盟', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('152900', '150000', '内蒙古自治区', '阿拉善盟', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210100', '210000', '辽宁省', '沈阳市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210200', '210000', '辽宁省', '大连市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210300', '210000', '辽宁省', '鞍山市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210400', '210000', '辽宁省', '抚顺市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210500', '210000', '辽宁省', '本溪市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210600', '210000', '辽宁省', '丹东市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210700', '210000', '辽宁省', '锦州市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210800', '210000', '辽宁省', '营口市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210900', '210000', '辽宁省', '阜新市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('211000', '210000', '辽宁省', '辽阳市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('211100', '210000', '辽宁省', '盘锦市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('211200', '210000', '辽宁省', '铁岭市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('211300', '210000', '辽宁省', '朝阳市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('211400', '210000', '辽宁省', '葫芦岛市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('220100', '220000', '吉林省', '长春市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('220200', '220000', '吉林省', '吉林市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('220300', '220000', '吉林省', '四平市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('220400', '220000', '吉林省', '辽源市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('220500', '220000', '吉林省', '通化市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('220600', '220000', '吉林省', '白山市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('220700', '220000', '吉林省', '松原市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('220800', '220000', '吉林省', '白城市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('222400', '220000', '吉林省', '延边朝鲜族自治州', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230100', '230000', '黑龙江省', '哈尔滨市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230200', '230000', '黑龙江省', '齐齐哈尔市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230300', '230000', '黑龙江省', '鸡西市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230400', '230000', '黑龙江省', '鹤岗市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230500', '230000', '黑龙江省', '双鸭山市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230600', '230000', '黑龙江省', '大庆市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230700', '230000', '黑龙江省', '伊春市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230800', '230000', '黑龙江省', '佳木斯市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230900', '230000', '黑龙江省', '七台河市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('231000', '230000', '黑龙江省', '牡丹江市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('231100', '230000', '黑龙江省', '黑河市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('231200', '230000', '黑龙江省', '绥化市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('232700', '230000', '黑龙江省', '大兴安岭地区', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('310000', '310000', '上海市', '上海市', '2026-09-20-v1', '2026-07-01', '公开城市参数整理(待发布前复核)', 7546, 37731, 2740, 37731, 0.08, 0.02, 0.005, 0.07, 0, 'CITY', TRUE), + ('320100', '320000', '江苏省', '南京市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('320200', '320000', '江苏省', '无锡市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('320300', '320000', '江苏省', '徐州市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('320400', '320000', '江苏省', '常州市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('320500', '320000', '江苏省', '苏州市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4952, 24762, 4952, 40600, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('320600', '320000', '江苏省', '南通市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('320700', '320000', '江苏省', '连云港市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('320800', '320000', '江苏省', '淮安市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('320900', '320000', '江苏省', '盐城市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('321000', '320000', '江苏省', '扬州市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('321100', '320000', '江苏省', '镇江市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('321200', '320000', '江苏省', '泰州市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('321300', '320000', '江苏省', '宿迁市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('330100', '330000', '浙江省', '杭州市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('330200', '330000', '浙江省', '宁波市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('330300', '330000', '浙江省', '温州市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('330400', '330000', '浙江省', '嘉兴市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('330500', '330000', '浙江省', '湖州市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('330600', '330000', '浙江省', '绍兴市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('330700', '330000', '浙江省', '金华市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('330800', '330000', '浙江省', '衢州市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('330900', '330000', '浙江省', '舟山市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('331000', '330000', '浙江省', '台州市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('331100', '330000', '浙江省', '丽水市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('340100', '340000', '安徽省', '合肥市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('340200', '340000', '安徽省', '芜湖市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('340300', '340000', '安徽省', '蚌埠市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('340400', '340000', '安徽省', '淮南市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('340500', '340000', '安徽省', '马鞍山市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('340600', '340000', '安徽省', '淮北市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('340700', '340000', '安徽省', '铜陵市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('340800', '340000', '安徽省', '安庆市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('341000', '340000', '安徽省', '黄山市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('341100', '340000', '安徽省', '滁州市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('341200', '340000', '安徽省', '阜阳市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('341300', '340000', '安徽省', '宿州市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('341500', '340000', '安徽省', '六安市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('341600', '340000', '安徽省', '亳州市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('341700', '340000', '安徽省', '池州市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('341800', '340000', '安徽省', '宣城市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350100', '350000', '福建省', '福州市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350200', '350000', '福建省', '厦门市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350300', '350000', '福建省', '莆田市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350400', '350000', '福建省', '三明市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350500', '350000', '福建省', '泉州市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350600', '350000', '福建省', '漳州市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350700', '350000', '福建省', '南平市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350800', '350000', '福建省', '龙岩市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350900', '350000', '福建省', '宁德市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360100', '360000', '江西省', '南昌市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360200', '360000', '江西省', '景德镇市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360300', '360000', '江西省', '萍乡市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360400', '360000', '江西省', '九江市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360500', '360000', '江西省', '新余市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360600', '360000', '江西省', '鹰潭市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360700', '360000', '江西省', '赣州市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360800', '360000', '江西省', '吉安市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360900', '360000', '江西省', '宜春市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('361000', '360000', '江西省', '抚州市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('361100', '360000', '江西省', '上饶市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370100', '370000', '山东省', '济南市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370200', '370000', '山东省', '青岛市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370300', '370000', '山东省', '淄博市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370400', '370000', '山东省', '枣庄市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370500', '370000', '山东省', '东营市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370600', '370000', '山东省', '烟台市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370700', '370000', '山东省', '潍坊市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370800', '370000', '山东省', '济宁市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370900', '370000', '山东省', '泰安市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('371000', '370000', '山东省', '威海市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('371100', '370000', '山东省', '日照市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('371300', '370000', '山东省', '临沂市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('371400', '370000', '山东省', '德州市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('371500', '370000', '山东省', '聊城市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('371600', '370000', '山东省', '滨州市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('371700', '370000', '山东省', '菏泽市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('410100', '410000', '河南省', '郑州市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('410200', '410000', '河南省', '开封市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('410300', '410000', '河南省', '洛阳市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('410400', '410000', '河南省', '平顶山市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('410500', '410000', '河南省', '安阳市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('410600', '410000', '河南省', '鹤壁市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('410700', '410000', '河南省', '新乡市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('410800', '410000', '河南省', '焦作市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('410900', '410000', '河南省', '濮阳市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('411000', '410000', '河南省', '许昌市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('411100', '410000', '河南省', '漯河市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('411200', '410000', '河南省', '三门峡市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('411300', '410000', '河南省', '南阳市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('411400', '410000', '河南省', '商丘市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('411500', '410000', '河南省', '信阳市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('411600', '410000', '河南省', '周口市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('411700', '410000', '河南省', '驻马店市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('419001', '410000', '河南省', '济源市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('420100', '420000', '湖北省', '武汉市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('420200', '420000', '湖北省', '黄石市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('420300', '420000', '湖北省', '十堰市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('420500', '420000', '湖北省', '宜昌市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('420600', '420000', '湖北省', '襄阳市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('420700', '420000', '湖北省', '鄂州市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('420800', '420000', '湖北省', '荆门市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('420900', '420000', '湖北省', '孝感市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('421000', '420000', '湖北省', '荆州市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('421100', '420000', '湖北省', '黄冈市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('421200', '420000', '湖北省', '咸宁市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('421300', '420000', '湖北省', '随州市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('422800', '420000', '湖北省', '恩施土家族苗族自治州', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('429004', '420000', '湖北省', '仙桃市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('429005', '420000', '湖北省', '潜江市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('429006', '420000', '湖北省', '天门市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430100', '430000', '湖南省', '长沙市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430200', '430000', '湖南省', '株洲市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430300', '430000', '湖南省', '湘潭市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430400', '430000', '湖南省', '衡阳市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430500', '430000', '湖南省', '邵阳市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430600', '430000', '湖南省', '岳阳市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430700', '430000', '湖南省', '常德市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430800', '430000', '湖南省', '张家界市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430900', '430000', '湖南省', '益阳市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('431000', '430000', '湖南省', '郴州市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('431100', '430000', '湖南省', '永州市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('431200', '430000', '湖南省', '怀化市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('431300', '430000', '湖南省', '娄底市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('433100', '430000', '湖南省', '湘西土家族苗族自治州', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('440100', '440000', '广东省', '广州市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('440200', '440000', '广东省', '韶关市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('440300', '440000', '广东省', '深圳市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 5510, 27549, 2520, 41190, 0.08, 0.02, 0.005, 0.05, 0, 'CITY', TRUE), + ('440400', '440000', '广东省', '珠海市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('440500', '440000', '广东省', '汕头市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('440600', '440000', '广东省', '佛山市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('440700', '440000', '广东省', '江门市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('440800', '440000', '广东省', '湛江市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('440900', '440000', '广东省', '茂名市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('441200', '440000', '广东省', '肇庆市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('441300', '440000', '广东省', '惠州市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('441400', '440000', '广东省', '梅州市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('441500', '440000', '广东省', '汕尾市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('441600', '440000', '广东省', '河源市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('441700', '440000', '广东省', '阳江市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('441800', '440000', '广东省', '清远市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('441900', '440000', '广东省', '东莞市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('442000', '440000', '广东省', '中山市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('445100', '440000', '广东省', '潮州市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('445200', '440000', '广东省', '揭阳市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('445300', '440000', '广东省', '云浮市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450100', '450000', '广西壮族自治区', '南宁市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450200', '450000', '广西壮族自治区', '柳州市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450300', '450000', '广西壮族自治区', '桂林市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450400', '450000', '广西壮族自治区', '梧州市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450500', '450000', '广西壮族自治区', '北海市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450600', '450000', '广西壮族自治区', '防城港市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450700', '450000', '广西壮族自治区', '钦州市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450800', '450000', '广西壮族自治区', '贵港市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450900', '450000', '广西壮族自治区', '玉林市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('451000', '450000', '广西壮族自治区', '百色市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('451100', '450000', '广西壮族自治区', '贺州市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('451200', '450000', '广西壮族自治区', '河池市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('451300', '450000', '广西壮族自治区', '来宾市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('451400', '450000', '广西壮族自治区', '崇左市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('460100', '460000', '海南省', '海口市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('460200', '460000', '海南省', '三亚市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('460300', '460000', '海南省', '三沙市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('460400', '460000', '海南省', '儋州市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('469001', '460000', '海南省', '五指山市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('469002', '460000', '海南省', '琼海市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('469005', '460000', '海南省', '文昌市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('469006', '460000', '海南省', '万宁市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('469007', '460000', '海南省', '东方市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('500000', '500000', '重庆市', '重庆市', '2026-09-20-v1', '2025-07-01', '重庆市省级公开参数回退基线,待城市级核验', 4404, 22017, 2000, 22017, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('500200', '500000', '重庆市', '县', '2026-09-20-v1', '2025-07-01', '重庆市省级公开参数回退基线,待城市级核验', 4404, 22017, 2000, 22017, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('510100', '510000', '四川省', '成都市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('510300', '510000', '四川省', '自贡市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('510400', '510000', '四川省', '攀枝花市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('510500', '510000', '四川省', '泸州市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('510600', '510000', '四川省', '德阳市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('510700', '510000', '四川省', '绵阳市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('510800', '510000', '四川省', '广元市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('510900', '510000', '四川省', '遂宁市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511000', '510000', '四川省', '内江市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511100', '510000', '四川省', '乐山市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511300', '510000', '四川省', '南充市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511400', '510000', '四川省', '眉山市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511500', '510000', '四川省', '宜宾市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511600', '510000', '四川省', '广安市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511700', '510000', '四川省', '达州市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511800', '510000', '四川省', '雅安市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511900', '510000', '四川省', '巴中市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('512000', '510000', '四川省', '资阳市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('513200', '510000', '四川省', '阿坝藏族羌族自治州', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('513300', '510000', '四川省', '甘孜藏族自治州', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('513400', '510000', '四川省', '凉山彝族自治州', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('520100', '520000', '贵州省', '贵阳市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('520200', '520000', '贵州省', '六盘水市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('520300', '520000', '贵州省', '遵义市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('520400', '520000', '贵州省', '安顺市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('520500', '520000', '贵州省', '毕节市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('520600', '520000', '贵州省', '铜仁市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('522300', '520000', '贵州省', '黔西南布依族苗族自治州', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('522600', '520000', '贵州省', '黔东南苗族侗族自治州', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('522700', '520000', '贵州省', '黔南布依族苗族自治州', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('530100', '530000', '云南省', '昆明市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('530300', '530000', '云南省', '曲靖市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('530400', '530000', '云南省', '玉溪市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('530500', '530000', '云南省', '保山市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('530600', '530000', '云南省', '昭通市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('530700', '530000', '云南省', '丽江市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('530800', '530000', '云南省', '普洱市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('530900', '530000', '云南省', '临沧市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('532300', '530000', '云南省', '楚雄彝族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('532500', '530000', '云南省', '红河哈尼族彝族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('532600', '530000', '云南省', '文山壮族苗族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('532800', '530000', '云南省', '西双版纳傣族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('532900', '530000', '云南省', '大理白族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('533100', '530000', '云南省', '德宏傣族景颇族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('533300', '530000', '云南省', '怒江傈僳族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('533400', '530000', '云南省', '迪庆藏族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('540100', '540000', '西藏自治区', '拉萨市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('540200', '540000', '西藏自治区', '日喀则市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('540300', '540000', '西藏自治区', '昌都市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('540400', '540000', '西藏自治区', '林芝市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('540500', '540000', '西藏自治区', '山南市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('540600', '540000', '西藏自治区', '那曲市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('542500', '540000', '西藏自治区', '阿里地区', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('610100', '610000', '陕西省', '西安市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('610200', '610000', '陕西省', '铜川市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('610300', '610000', '陕西省', '宝鸡市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('610400', '610000', '陕西省', '咸阳市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('610500', '610000', '陕西省', '渭南市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('610600', '610000', '陕西省', '延安市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('610700', '610000', '陕西省', '汉中市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('610800', '610000', '陕西省', '榆林市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('610900', '610000', '陕西省', '安康市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('611000', '610000', '陕西省', '商洛市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620100', '620000', '甘肃省', '兰州市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620200', '620000', '甘肃省', '嘉峪关市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620300', '620000', '甘肃省', '金昌市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620400', '620000', '甘肃省', '白银市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620500', '620000', '甘肃省', '天水市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620600', '620000', '甘肃省', '武威市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620700', '620000', '甘肃省', '张掖市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620800', '620000', '甘肃省', '平凉市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620900', '620000', '甘肃省', '酒泉市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('621000', '620000', '甘肃省', '庆阳市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('621100', '620000', '甘肃省', '定西市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('621200', '620000', '甘肃省', '陇南市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('622900', '620000', '甘肃省', '临夏回族自治州', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('623000', '620000', '甘肃省', '甘南藏族自治州', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('630100', '630000', '青海省', '西宁市', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('630200', '630000', '青海省', '海东市', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('632200', '630000', '青海省', '海北藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('632300', '630000', '青海省', '黄南藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('632500', '630000', '青海省', '海南藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('632600', '630000', '青海省', '果洛藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('632700', '630000', '青海省', '玉树藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('632800', '630000', '青海省', '海西蒙古族藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('640100', '640000', '宁夏回族自治区', '银川市', '2026-09-20-v1', '2025-07-01', '宁夏回族自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('640200', '640000', '宁夏回族自治区', '石嘴山市', '2026-09-20-v1', '2025-07-01', '宁夏回族自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('640300', '640000', '宁夏回族自治区', '吴忠市', '2026-09-20-v1', '2025-07-01', '宁夏回族自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('640400', '640000', '宁夏回族自治区', '固原市', '2026-09-20-v1', '2025-07-01', '宁夏回族自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('640500', '640000', '宁夏回族自治区', '中卫市', '2026-09-20-v1', '2025-07-01', '宁夏回族自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('650100', '650000', '新疆维吾尔自治区', '乌鲁木齐市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('650200', '650000', '新疆维吾尔自治区', '克拉玛依市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('650400', '650000', '新疆维吾尔自治区', '吐鲁番市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('650500', '650000', '新疆维吾尔自治区', '哈密市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('652300', '650000', '新疆维吾尔自治区', '昌吉回族自治州', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('652700', '650000', '新疆维吾尔自治区', '博尔塔拉蒙古自治州', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('652800', '650000', '新疆维吾尔自治区', '巴音郭楞蒙古自治州', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('652900', '650000', '新疆维吾尔自治区', '阿克苏地区', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('653000', '650000', '新疆维吾尔自治区', '克孜勒苏柯尔克孜自治州', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('653100', '650000', '新疆维吾尔自治区', '喀什地区', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('653200', '650000', '新疆维吾尔自治区', '和田地区', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('654000', '650000', '新疆维吾尔自治区', '伊犁哈萨克自治州', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('654200', '650000', '新疆维吾尔自治区', '塔城地区', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('654300', '650000', '新疆维吾尔自治区', '阿勒泰地区', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659001', '650000', '新疆维吾尔自治区', '石河子市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659002', '650000', '新疆维吾尔自治区', '阿拉尔市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659003', '650000', '新疆维吾尔自治区', '图木舒克市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659004', '650000', '新疆维吾尔自治区', '五家渠市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659005', '650000', '新疆维吾尔自治区', '北屯市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659006', '650000', '新疆维吾尔自治区', '铁门关市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659007', '650000', '新疆维吾尔自治区', '双河市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659008', '650000', '新疆维吾尔自治区', '可克达拉市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659009', '650000', '新疆维吾尔自治区', '昆玉市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659010', '650000', '新疆维吾尔自治区', '胡杨河市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659011', '650000', '新疆维吾尔自治区', '新星市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659012', '650000', '新疆维吾尔自治区', '白杨市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE) +; 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 792524d..48e3008 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java @@ -442,19 +442,28 @@ class CompanyResumeDownloadIntegrationTest { long pathCount = paths.size(); long opCount = 0; + long modulePaths = 0; + long moduleOperations = 0; var it = paths.fieldNames(); while (it.hasNext()) { - var ops = paths.path(it.next()); + String path = it.next(); + boolean module = path.startsWith("/skills/") || path.startsWith("/onboarding/") + || path.startsWith("/salary-calculator/"); + if (module) modulePaths++; + var ops = paths.path(path); var f = ops.fieldNames(); while (f.hasNext()) { if (java.util.Set.of("get", "post", "put", "delete", "patch") .contains(f.next())) { opCount++; + if (module) moduleOperations++; } } } - assertThat(pathCount).as("统一账号身份选择新增一个路径后应为 183 paths").isEqualTo(183); - assertThat(opCount).as("统一账号身份选择新增一个 operation 后应为 221 operations").isEqualTo(221); + assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13); + assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18); + assertThat(pathCount - modulePaths).as("既有 183 paths 保持不变").isEqualTo(183); + assertThat(opCount - moduleOperations).as("既有 221 operations 保持不变").isEqualTo(221); 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/UnifiedRegistrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java index e53cd22..2d61e73 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 @@ -191,6 +191,7 @@ class UnifiedRegistrationIntegrationTest { "/auth/password/change","/auth/login","/auth/password/login","/auth/password/reset", "/auth/refresh","/auth/sms/send","/auth/company/register"); int denied=0; + int moduleDenied=0; var paths=doc.path("paths").fields(); while(paths.hasNext()) { var path=paths.next(); @@ -204,9 +205,12 @@ class UnifiedRegistrationIntegrationTest { .header("Authorization","Bearer "+token).contentType(MediaType.APPLICATION_JSON).content("{}")) .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(10401)); denied++; + if (path.getKey().startsWith("/skills/") || path.getKey().startsWith("/onboarding/") + || path.getKey().startsWith("/salary-calculator/")) moduleDenied++; } } - assertThat(denied).isEqualTo(196); + assertThat(moduleDenied).as("新模块全部拒绝未选择身份账号").isEqualTo(18); + assertThat(denied - moduleDenied).as("既有身份门禁范围不减少").isEqualTo(196); 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/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java index 391460a..86dd6d1 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java @@ -74,7 +74,7 @@ class CandidateScoringRefreshClaimTokenTest { } private Long newTask() { - requestService.requestRefresh(sid, LocalDateTime.now()); + requestService.requestRefresh(sid, SAME_INSTANT.minusSeconds(1)); return jdbcTemplate.queryForObject( "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid); } @@ -99,7 +99,8 @@ class CandidateScoringRefreshClaimTokenTest { sid = userRepository.save(u).getId(); jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); - LocalDateTime n = LocalDateTime.now(); + // 创建、领取和追加请求必须使用同一固定月份,避免跨月运行时生成另一条任务。 + LocalDateTime n = SAME_INSTANT; period = String.format("%04d-%02d", n.getYear(), n.getMonthValue()); } @@ -197,7 +198,7 @@ class CandidateScoringRefreshClaimTokenTest { String token1 = claimToken(t1); Long sid2 = newStudent(); - requestService.requestRefresh(sid2, LocalDateTime.now()); + requestService.requestRefresh(sid2, SAME_INSTANT.minusSeconds(1)); Long t2 = jdbcTemplate.queryForObject( "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid2); CandidateScoringRefreshTaskExecutor.ClaimedTask c2 = taskExecutor.claim(t2, SAME_INSTANT); @@ -237,7 +238,7 @@ class CandidateScoringRefreshClaimTokenTest { assertNotNull(a); String ta = claimToken(taskId); - taskExecutor.recoverStale(LocalDateTime.now(), LocalDateTime.now(), 3); + taskExecutor.recoverStale(SAME_INSTANT.plusSeconds(30), SAME_INSTANT.plusSeconds(60), 3); assertEquals("RETRY", status(taskId)); assertNull(claimToken(taskId), "回收后应清空 claim_token"); @@ -258,7 +259,7 @@ class CandidateScoringRefreshClaimTokenTest { // PENDING 回退 Long sid2 = newStudent(); - requestService.requestRefresh(sid2, LocalDateTime.now()); + requestService.requestRefresh(sid2, SAME_INSTANT.minusSeconds(1)); Long t2 = jdbcTemplate.queryForObject( "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid2); CandidateScoringRefreshTaskExecutor.ClaimedTask c2 = taskExecutor.claim(t2, SAME_INSTANT); @@ -270,7 +271,7 @@ class CandidateScoringRefreshClaimTokenTest { // RETRY Long sid3 = newStudent(); - requestService.requestRefresh(sid3, LocalDateTime.now()); + requestService.requestRefresh(sid3, SAME_INSTANT.minusSeconds(1)); Long t3 = jdbcTemplate.queryForObject( "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid3); CandidateScoringRefreshTaskExecutor.ClaimedTask c3 = taskExecutor.claim(t3, SAME_INSTANT); @@ -310,7 +311,7 @@ class CandidateScoringRefreshClaimTokenTest { assertTrue(token.matches(".*[0-9a-f]{8}-.*"), "token 必须是随机 UUID 形态而非 ID 拼接"); // 再领一次必须得到不同 token(不可复用)—— 用独立学生,避免 (student,period) 合并 Long otherStudent = newStudent(); - requestService.requestRefresh(otherStudent, LocalDateTime.now()); + requestService.requestRefresh(otherStudent, SAME_INSTANT.minusSeconds(1)); Long t2 = jdbcTemplate.queryForObject( "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, otherStudent); @@ -410,8 +411,8 @@ class CandidateScoringRefreshClaimTokenTest { return userRepository.save(u).getId(); } - /** R01-C 阶段二:本类会以**当前 period** 物化快照。残留行会进入同库中其它测试 - * 对"当前期间"的总体统计(如 Fairness 的 populationSize),造成顺序相关误失败。 + /** R01-C 阶段二:本类会物化固定期间的快照。残留行会进入同库中其它测试 + * 的总体统计(如 Fairness 的 populationSize),造成顺序相关误失败。 * 此处按本类**独占手机号前缀**解析出精确 id,再按外键顺序删除;不使用 deleteAll。 */ @AfterEach void cleanupOwnFixture() { 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 e27dac2..5d40bd7 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 @@ -101,8 +101,22 @@ class GlobalExceptionHandlerStatusContractTest { assertEquals(HttpStatus.BAD_REQUEST, new ErrorHttpStatusRegistry().resolve(123456789)); } + private static final java.util.Map MODULE_ADDITIONS = java.util.Map.ofEntries( + 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), + java.util.Map.entry("ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE", HttpStatus.CONFLICT), + java.util.Map.entry("ONBOARDING_ITEM_LIMIT_EXCEEDED", HttpStatus.CONFLICT), + java.util.Map.entry("ONBOARDING_IDEMPOTENCY_CONFLICT", HttpStatus.CONFLICT), + java.util.Map.entry("ONBOARDING_PROGRESS_CONCURRENT_UPDATE", HttpStatus.CONFLICT), + java.util.Map.entry("ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE", HttpStatus.CONFLICT), + java.util.Map.entry("SALARY_IDEMPOTENCY_CONFLICT", HttpStatus.CONFLICT), + java.util.Map.entry("SALARY_POLICY_VERSION_CONFLICT", HttpStatus.CONFLICT), + java.util.Map.entry("SALARY_INVALID_PARAMETER", HttpStatus.BAD_REQUEST)); + private HttpStatus expectedStatus(ErrorCode errorCode) { String name = errorCode.name(); + if (MODULE_ADDITIONS.containsKey(name)) return MODULE_ADDITIONS.get(name); if (FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN; if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND; if (CONFLICT.contains(name)) return HttpStatus.CONFLICT; 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 7c0ef2c..3b4a1fe 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(123); + assertThat(registry.explicitMappingCount()).isEqualTo(133); // 123 个常量只覆盖 122 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与 // DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。 - assertThat(registry.explicitNumericCodeCount()).isEqualTo(122); + assertThat(registry.explicitNumericCodeCount()).isEqualTo(132); 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(123); + assertThat(view).hasSize(133); Map numeric = ErrorHttpStatusRegistry.numericMappingsView(); assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK)) .isInstanceOf(UnsupportedOperationException.class); - assertThat(numeric).hasSize(122); + assertThat(numeric).hasSize(132); } @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 24b9cf9..1d67093 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 @@ -96,8 +96,22 @@ class GlobalExceptionHandlerMappingParityTest { private final GlobalExceptionHandler handler = new GlobalExceptionHandler(new RequestIdAccessor(), registry); + private static final java.util.Map MODULE_ADDITIONS = java.util.Map.ofEntries( + 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), + java.util.Map.entry("ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE", HttpStatus.CONFLICT), + java.util.Map.entry("ONBOARDING_ITEM_LIMIT_EXCEEDED", HttpStatus.CONFLICT), + java.util.Map.entry("ONBOARDING_IDEMPOTENCY_CONFLICT", HttpStatus.CONFLICT), + java.util.Map.entry("ONBOARDING_PROGRESS_CONCURRENT_UPDATE", HttpStatus.CONFLICT), + java.util.Map.entry("ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE", HttpStatus.CONFLICT), + java.util.Map.entry("SALARY_IDEMPOTENCY_CONFLICT", HttpStatus.CONFLICT), + java.util.Map.entry("SALARY_POLICY_VERSION_CONFLICT", HttpStatus.CONFLICT), + java.util.Map.entry("SALARY_INVALID_PARAMETER", HttpStatus.BAD_REQUEST)); + private static HttpStatus baselineStatus(ErrorCode errorCode) { String name = errorCode.name(); + if (MODULE_ADDITIONS.containsKey(name)) return MODULE_ADDITIONS.get(name); // 新功能追加映射与历史快照分离,不改历史错误码的期望值。 if (name.equals("IDENTITY_SELECTION_REQUIRED")) return HttpStatus.FORBIDDEN; if (name.equals("IDENTITY_SELECTION_CONFLICT")) return HttpStatus.CONFLICT; @@ -118,8 +132,8 @@ class GlobalExceptionHandlerMappingParityTest { @Test @DisplayName("B2:显式映射集合与重构前快照逐项相等") void explicitMappingSetEqualsPreRefactorSnapshot() { - assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(123); - assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(122); + assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(133); + assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(132); 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 f48bc64..554dea4 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 @@ -47,6 +47,19 @@ class R01BIndependentHttpMappingParityTest { "IDENTITY_SELECTION_CONFLICT", HttpStatus.CONFLICT, "IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE", HttpStatus.SERVICE_UNAVAILABLE); + private static final java.util.Map MODULE_ADDITIONS = java.util.Map.ofEntries( + 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), + java.util.Map.entry("ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE", HttpStatus.CONFLICT), + java.util.Map.entry("ONBOARDING_ITEM_LIMIT_EXCEEDED", HttpStatus.CONFLICT), + java.util.Map.entry("ONBOARDING_IDEMPOTENCY_CONFLICT", HttpStatus.CONFLICT), + java.util.Map.entry("ONBOARDING_PROGRESS_CONCURRENT_UPDATE", HttpStatus.CONFLICT), + java.util.Map.entry("ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE", HttpStatus.CONFLICT), + java.util.Map.entry("SALARY_IDEMPOTENCY_CONFLICT", HttpStatus.CONFLICT), + java.util.Map.entry("SALARY_POLICY_VERSION_CONFLICT", HttpStatus.CONFLICT), + java.util.Map.entry("SALARY_INVALID_PARAMETER", HttpStatus.BAD_REQUEST)); + private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); private final GlobalExceptionHandler handler = new GlobalExceptionHandler(new RequestIdAccessor(), registry); @@ -92,7 +105,7 @@ class R01BIndependentHttpMappingParityTest { // HEAD 基线有 169 条;后续阶段新增码单独列出,不修改任何历史基线行。 assertThat(base).hasSize(169); assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK"); - assertThat(ErrorCode.values()).hasSize(177); + assertThat(ErrorCode.values()).hasSize(188); int checked = 0; for (ErrorCode code : ErrorCode.values()) { @@ -101,6 +114,7 @@ class R01BIndependentHttpMappingParityTest { HttpStatus addedStatus = PHASE_A_ADDITIONS.get(code.name()); if (addedStatus == null) addedStatus = PHASE_B_ADDITIONS.get(code.name()); if (addedStatus == null) addedStatus = IDENTITY_ADDITIONS.get(code.name()); + if (addedStatus == null) addedStatus = MODULE_ADDITIONS.get(code.name()); assertThat(addedStatus).as("新增错误码必须逐项明确登记: %s", code.name()).isNotNull(); assertThat(registry.resolve(code)).isEqualTo(addedStatus); checked++; @@ -113,7 +127,7 @@ class R01BIndependentHttpMappingParityTest { .isEqualTo(expected[1]); checked++; } - assertThat(checked).isEqualTo(177); + assertThat(checked).isEqualTo(188); } @Test @@ -125,10 +139,10 @@ class R01BIndependentHttpMappingParityTest { assertThat(explicitInBaseline).isEqualTo(115); assertThat(base.size() - explicitInBaseline).isEqualTo(54); - assertThat(registry.explicitMappingCount()).isEqualTo(123); - assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54); + assertThat(registry.explicitMappingCount()).isEqualTo(133); + assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(55); // 123 个显式常量覆盖 122 个唯一数字码(9103 共用)。 - assertThat(registry.explicitNumericCodeCount()).isEqualTo(122); + assertThat(registry.explicitNumericCodeCount()).isEqualTo(132); } @Test @@ -240,10 +254,12 @@ class R01BIndependentHttpMappingParityTest { // 真实注册表对每个枚举常量的解析与其声明位置无关:逐个比对基线即可证明 Map base = baseline(); for (ErrorCode c : ErrorCode.values()) { - if (PHASE_A_ADDITIONS.containsKey(c.name()) || PHASE_B_ADDITIONS.containsKey(c.name()) || IDENTITY_ADDITIONS.containsKey(c.name())) { + if (PHASE_A_ADDITIONS.containsKey(c.name()) || PHASE_B_ADDITIONS.containsKey(c.name()) + || IDENTITY_ADDITIONS.containsKey(c.name()) || MODULE_ADDITIONS.containsKey(c.name())) { HttpStatus addedStatus = PHASE_A_ADDITIONS.get(c.name()); if (addedStatus == null) addedStatus = PHASE_B_ADDITIONS.get(c.name()); if (addedStatus == null) addedStatus = IDENTITY_ADDITIONS.get(c.name()); + if (addedStatus == null) addedStatus = MODULE_ADDITIONS.get(c.name()); assertThat(registry.resolve(c)).isEqualTo(addedStatus); } else { assertThat(registry.resolve(c).value()).isEqualTo(base.get(c.name())[1]); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqIndependentMySqlFunctionalVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqIndependentMySqlFunctionalVerificationTest.java new file mode 100644 index 0000000..6f282a8 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqIndependentMySqlFunctionalVerificationTest.java @@ -0,0 +1,351 @@ +package com.mydreamjob.integration; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.auth.AuthSessionService; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.salarycalculator.SalaryCalculationHistory; +import com.mydreamjob.salarycalculator.SalaryCalculationHistoryRepository; +import com.mydreamjob.salarycalculator.SalaryRequestFingerprint; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +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.boot.test.mock.mockito.SpyBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** 独立 MySQL HTTP 链路证据;请求指纹处同步并发请求,历史写入后注入回滚故障。 */ +@Testcontainers(disabledWithoutDocker = false) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("mysql-it") +class LxqIndependentMySqlFunctionalVerificationTest { + @Container static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("lxq_independent").withUsername("independent_test") + .withPassword("independent-test-only"); + @DynamicPropertySource static void properties(DynamicPropertyRegistry r) { + r.add("spring.datasource.url", MYSQL::getJdbcUrl); + r.add("spring.datasource.username", MYSQL::getUsername); + r.add("spring.datasource.password", MYSQL::getPassword); + } + @MockBean RedisTemplate redis; + @SpyBean SalaryRequestFingerprint fingerprint; + @SpyBean SalaryCalculationHistoryRepository histories; + @Autowired MockMvc mvc; + @Autowired ObjectMapper mapper; + @Autowired JdbcTemplate jdbc; + @Autowired UserRepository users; + @Autowired CompanyRepository companies; + @Autowired AuthSessionService sessions; + @PersistenceContext EntityManager entityManager; + private static final AtomicInteger SEQUENCE = new AtomicInteger(); + private final List ownedUsers = new ArrayList<>(); + private final List ownedCompanies = new ArrayList<>(); + private record Identity(long id, String token) {} + private Identity student, otherStudent, companyUser, otherCompanyUser, admin; + private long companyId, otherCompanyId; + + @BeforeEach void fixtures() { + student = identity(User.Role.STUDENT); + otherStudent = identity(User.Role.STUDENT); + companyUser = identity(User.Role.COMPANY); + otherCompanyUser = identity(User.Role.COMPANY); + admin = identity(User.Role.ADMIN); + companyId = company(companyUser.id(), true); + otherCompanyId = company(otherCompanyUser.id(), true); + } + private Identity identity(User.Role role) { + User user = users.saveAndFlush(User.builder() + .phone("1990021" + String.format("%04d", SEQUENCE.incrementAndGet())) + .role(role).roles(new HashSet<>(Set.of(role))).active(true).verified(true).build()); + ownedUsers.add(user.getId()); + var tokens = sessions.createSession(user, Instant.now(), AuditContext.builder() + .requestId("lxq-independent-fixture").build()); + return new Identity(user.getId(), tokens.accessToken()); + } + private long company(long owner, boolean verified) { + Company c = companies.saveAndFlush(Company.builder().ownerUserId(owner) + .name("独立复验临时企业").verified(verified).build()); + ownedCompanies.add(c.getId()); + return c.getId(); + } + @AfterEach void cleanup() { + reset(fingerprint, histories); + for (long id : ownedUsers) { + jdbc.update("delete from student_onboarding_progress where student_user_id=?", id); + jdbc.update("delete from salary_calculation_history where student_user_id=?", id); + jdbc.update("delete from student_profiles where user_id=?", id); + jdbc.update("delete from auth_tokens where user_id=?", id); + } + for (long id : ownedCompanies) { + jdbc.update("delete from student_onboarding_progress where item_id in (select id from onboarding_checklist_items where company_id=?)", id); + jdbc.update("delete from onboarding_checklist_items where company_id=?", id); + jdbc.update("delete from company_knowledge where company_id=?", id); + jdbc.update("delete from company_verifications where company_id=?", id); + jdbc.update("delete from companies where id=?", id); + } + for (long id : ownedUsers) { + jdbc.update("delete from user_roles where user_id=?", id); + jdbc.update("delete from users where id=?", id); + assertThat(jdbc.queryForObject("select count(*) from users where id=?", Long.class, id)).isZero(); + } + // 审计 append-only,不删除;容器销毁后测试库整体结束。 + } + private JsonNode okGet(String path, Identity who) throws Exception { + return mapper.readTree(mvc.perform(get(path).header("Authorization", "Bearer " + who.token())) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()).path("data"); + } + private JsonNode salaryRequest(String key, String amount) { + var request = mapper.createObjectNode(); + request.put("cityCode", "110000"); + request.put("grossMonthlySalary", new BigDecimal(amount)); + request.put("monthIndex", 1); + request.put("specialAdditionalDeduction", 0); + request.put("expectedPolicyVersion", jdbc.queryForObject( + "select policy_version from salary_city_policy where city_code='110000'", String.class)); + request.put("idempotencyKey", key); + return request; + } + private MvcResult calculate(JsonNode request) throws Exception { + return mvc.perform(post("/salary-calculator/calculations") + .header("Authorization", "Bearer " + student.token()) + .contentType(MediaType.APPLICATION_JSON).content(request.toString())).andReturn(); + } + + @Test void n03MatchAndLearningPathUsePersistedSkillsAndReadOnlyDatabase() throws Exception { + JsonNode cloud = okGet("/skills/word-cloud?industryId=1&occupationId=1", student); + assertThat(cloud.path("items").size()).isGreaterThanOrEqualTo(10); + String skill = cloud.path("items").get(0).path("nameZh").asText(); + jdbc.update("insert into student_profiles(user_id,skills_json) values(?,?)", student.id(), + mapper.writeValueAsString(List.of(skill))); + Map before = snapshot(); + JsonNode match = okGet("/skills/match?industryId=1&occupationId=1", student); + assertThat(match.path("matchedSkills").toString()).contains(skill); + assertThat(match.path("totalCount").asInt()).isEqualTo(10); + assertThat(match.path("matchedCount").asInt() + match.path("missingSkills").size()).isEqualTo(10); + assertThat(match.path("matchScore").asInt()).isEqualTo(match.path("matchedCount").asInt() * 10); + JsonNode learning = okGet("/skills/learning-path?industryId=1&occupationId=1", student); + assertThat(learning.path("items").size()).isEqualTo(match.path("missingSkills").size()); + for (int i = 0; i < learning.path("items").size(); i++) { + assertThat(learning.path("items").get(i).path("skillId").asLong()) + .isEqualTo(match.path("missingSkills").get(i).path("skillId").asLong()); + } + assertThat(snapshot()).isEqualTo(before); + } + + @ParameterizedTest @ValueSource(booleans={false,true}) + void n04UnverifiedCompanyCannotAccessAnyCompanyEntry(boolean pending) throws Exception { + jdbc.update("update companies set verified=0 where id=?", companyId); + if (pending) { + jdbc.update("insert into company_verifications(company_id,status,submitted_by,materials_json) values(?,'PENDING',?,'{}')", companyId, companyUser.id()); + } + Map before = snapshot(); + for (String method : List.of("GET", "POST", "PUT", "DELETE")) { + String path = method.equals("PUT") || method.equals("DELETE") + ? "/onboarding/company/checklist-items/1?version=0" : "/onboarding/company/checklist-items"; + mvc.perform(request(org.springframework.http.HttpMethod.valueOf(method), path) + .header("Authorization", "Bearer " + companyUser.token()) + .contentType(MediaType.APPLICATION_JSON).content("{\"title\":\"检查项\",\"category\":\"OTHER\",\"sortOrder\":1,\"required\":false,\"active\":true,\"version\":0}")) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(5004)); + } + for (String method : List.of("GET", "PUT")) { + mvc.perform(request(org.springframework.http.HttpMethod.valueOf(method), "/onboarding/company/knowledge") + .header("Authorization", "Bearer " + companyUser.token()) + .contentType(MediaType.APPLICATION_JSON).content("{\"culture\":\"团队文化\",\"version\":0}")) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(5004)); + } + assertThat(snapshot()).isEqualTo(before); + jdbc.update("delete from company_verifications where company_id=?", companyId); + } + + @Test void n04ChecklistOwnershipProgressIsolationAndKnowledgePersistCorrectly() throws Exception { + MvcResult created = mvc.perform(post("/onboarding/company/checklist-items") + .header("Authorization", "Bearer " + companyUser.token()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"独立检查项\",\"category\":\"OTHER\",\"idempotencyKey\":\"independent-item\"}")) + .andExpect(status().isOk()).andReturn(); + long itemId = mapper.readTree(created.getResponse().getContentAsByteArray()).path("data").path("itemId").asLong(); + assertThat(itemId).isPositive(); + mvc.perform(delete("/onboarding/company/checklist-items/" + itemId + "?version=0") + .header("Authorization", "Bearer " + otherCompanyUser.token())) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(10501)); + String studentPath = "/onboarding/companies/" + companyId + "/checklist"; + mvc.perform(put(studentPath + "/items/" + itemId + "/completion") + .header("Authorization", "Bearer " + student.token()).contentType(MediaType.APPLICATION_JSON) + .content("{\"completed\":true,\"version\":0}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.completed").value(true)); + assertThat(okGet(studentPath, student).path("completedItems").asInt()).isEqualTo(1); + assertThat(okGet(studentPath, otherStudent).path("completedItems").asInt()).isZero(); + assertThat(okGet("/onboarding/companies/" + otherCompanyId + "/checklist", student) + .path("items").toString()).doesNotContain("独立检查项"); + mvc.perform(put("/onboarding/company/knowledge").header("Authorization", "Bearer " + companyUser.token()) + .contentType(MediaType.APPLICATION_JSON).content("{\"culture\":\"团队文化\",\"version\":0}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.culture").value("团队文化")); + Map before = snapshot(); + JsonNode knowledge = okGet("/onboarding/companies/" + companyId + "/knowledge", student); + assertThat(knowledge.toString()).contains("团队文化").doesNotContain("ownerUserId", "passwordHash", "phone", "materialsJson"); + assertThat(snapshot()).isEqualTo(before); + } + + @Test void n05MoneyReplayJsonOwnershipAndDeleteAreRealMySqlBehavior() throws Exception { + JsonNode request = salaryRequest("independent-sequential", "10000.00"); + MvcResult first = calculate(request); + assertThat(first.getResponse().getStatus()).isEqualTo(200); + JsonNode data = mapper.readTree(first.getResponse().getContentAsByteArray()).path("data"); + long id = data.path("historyId").asLong(); + assertThat(data.path("result").path("netSalary").decimalValue()).isEqualByComparingTo("7667.50"); + MvcResult repeated = calculate(request); + assertThat(repeated.getResponse().getStatus()).isEqualTo(200); + assertThat(mapper.readTree(repeated.getResponse().getContentAsByteArray()).path("data").path("historyId").asLong()).isEqualTo(id); + assertThat(jdbc.queryForObject("select count(*) from salary_calculation_history where student_user_id=?", Long.class, student.id())).isEqualTo(1); + JsonNode detail = okGet("/salary-calculator/calculations/" + id, student); + assertThat(detail.path("inputSnapshot").isObject()).isTrue(); + assertThat(detail.path("policySnapshot").isObject()).isTrue(); + assertThat(detail.path("resultSnapshot").isObject()).isTrue(); + mvc.perform(get("/salary-calculator/calculations/" + id).header("Authorization", "Bearer " + otherStudent.token())) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(10603)); + mvc.perform(delete("/salary-calculator/calculations/" + id).header("Authorization", "Bearer " + otherStudent.token())) + .andExpect(status().isNotFound()); + assertThat(calculate(salaryRequest("independent-sequential", "11000.00")).getResponse().getStatus()).isEqualTo(409); + mvc.perform(delete("/salary-calculator/calculations/" + id).header("Authorization", "Bearer " + student.token())) + .andExpect(status().isOk()); + assertThat(jdbc.queryForObject("select count(*) from salary_calculation_history where id=?", Long.class, id)).isZero(); + } + + @Test void n05ConcurrentSameKeyMustReplayWithoutRollbackOr500() throws Exception { + JsonNode request = salaryRequest("independent-parallel", "10000.00"); + List results = concurrentCalculations(request, request); + assertThat(jdbc.queryForObject("select count(*) from salary_calculation_history where student_user_id=?", Long.class, student.id())).isEqualTo(1); + assertThat(results.stream().map(r -> r.getResponse().getStatus()).toList()) + .as("同键同请求应均成功并回放同一已提交历史").containsExactly(200, 200); + assertThat(mapper.readTree(results.get(0).getResponse().getContentAsByteArray()).path("data").path("historyId")) + .isEqualTo(mapper.readTree(results.get(1).getResponse().getContentAsByteArray()).path("data").path("historyId")); + } + + @Test void n05ConcurrentSameKeyDifferentBodyMustConflictWithoutOverwritingWinner() throws Exception { + List results = concurrentCalculations( + salaryRequest("independent-conflict", "10000.00"), + salaryRequest("independent-conflict", "11000.00")); + assertThat(results.stream().map(r -> r.getResponse().getStatus()).toList()) + .containsExactlyInAnyOrder(200, 409); + MvcResult winner = results.stream().filter(r -> r.getResponse().getStatus() == 200).findFirst().orElseThrow(); + MvcResult loser = results.stream().filter(r -> r.getResponse().getStatus() == 409).findFirst().orElseThrow(); + assertThat(mapper.readTree(loser.getResponse().getContentAsByteArray()).path("code").asInt()).isEqualTo(10604); + JsonNode winningData = mapper.readTree(winner.getResponse().getContentAsByteArray()).path("data"); + long id = winningData.path("historyId").asLong(); + assertThat(jdbc.queryForObject("select count(*) from salary_calculation_history where student_user_id=?", Long.class, student.id())).isEqualTo(1); + assertThat(jdbc.queryForObject("select gross_monthly_salary from salary_calculation_history where id=?", BigDecimal.class, id)) + .isEqualByComparingTo(winningData.path("result").path("grossMonthlySalary").decimalValue()); + } + + @Test void n05FailureAfterFlushRollsBackAndSameKeyCanRetry() throws Exception { + Map before = snapshot(); + AtomicBoolean flushed = new AtomicBoolean(); + doAnswer(invocation -> { + // Spring Data 接口代理没有可供 Mockito 调用的具体方法,直接在当前真实事务中执行 INSERT。 + entityManager.persist(invocation.getArgument(0, SalaryCalculationHistory.class)); + entityManager.flush(); + assertThat(jdbc.queryForObject("select count(*) from salary_calculation_history where student_user_id=?", Long.class, student.id())).isEqualTo(1); + flushed.set(true); + throw new IllegalStateException("independent salary rollback injection"); + }).when(histories).saveAndFlush(any(SalaryCalculationHistory.class)); + JsonNode request = salaryRequest("independent-rollback", "10000.00"); + MvcResult failed = calculate(request); + assertThat(flushed).as("故障必须发生在真实 INSERT 已完成之后").isTrue(); + assertThat(failed.getResponse().getStatus()).isEqualTo(500); + assertThat(failed.getResponse().getContentAsString()) + .doesNotContain("Exception", "SQL", "rollback injection", "org.hibernate"); + assertThat(snapshot()).isEqualTo(before); + reset(histories); + MvcResult retried = calculate(request); + assertThat(retried.getResponse().getStatus()).isEqualTo(200); + assertThat(jdbc.queryForObject("select count(*) from salary_calculation_history where student_user_id=?", Long.class, student.id())).isEqualTo(1); + } + + private List concurrentCalculations(JsonNode leftRequest, JsonNode rightRequest) throws Exception { + CyclicBarrier bothRequests = new CyclicBarrier(2); + doAnswer(invocation -> { + bothRequests.await(15, TimeUnit.SECONDS); + return invocation.callRealMethod(); + }).when(fingerprint).fingerprint(any(SalaryCalculationRequest.class)); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future left = pool.submit(() -> calculate(leftRequest)); + Future right = pool.submit(() -> calculate(rightRequest)); + MvcResult l = left.get(35, TimeUnit.SECONDS), r = right.get(35, TimeUnit.SECONDS); + for (MvcResult result : List.of(l, r)) { + assertThat(result.getResponse().getContentAsString()).doesNotContain("Exception", "SQL", "stackTrace", "org.hibernate"); + } + return List.of(l, r); + } finally { + pool.shutdownNow(); + assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue(); + reset(fingerprint); + } + } + + @Test void roleAndTokenBoundariesRemainClosed() throws Exception { + Map before = snapshot(); + for (String path : List.of("/skills/word-cloud?industryId=1&occupationId=1", "/salary-calculator/cities")) { + mvc.perform(get(path)).andExpect(status().isUnauthorized()); + mvc.perform(get(path).header("Authorization", "Bearer invalid-test-token")).andExpect(status().isUnauthorized()); + for (Identity denied : List.of(companyUser, admin)) { + mvc.perform(get(path).header("Authorization", "Bearer " + denied.token())).andExpect(status().isForbidden()); + } + } + for (Identity denied : List.of(student, admin)) { + mvc.perform(get("/onboarding/company/knowledge").header("Authorization", "Bearer " + denied.token())) + .andExpect(status().isForbidden()); + } + assertThat(snapshot()).isEqualTo(before); + } + + private Map snapshot() { + Map result = new TreeMap<>(); + List tables = jdbc.queryForList("select table_name from information_schema.tables where table_schema=database() and table_type='BASE TABLE' order by table_name", String.class); + for (String table : tables) { + List keys = jdbc.queryForList("select column_name from information_schema.key_column_usage where table_schema=database() and table_name=? and constraint_name='PRIMARY' order by ordinal_position", String.class, table); + String order = keys.isEmpty() ? "" : " order by " + String.join(",", keys.stream().map(k -> "`" + k + "`").toList()); + List> rows = jdbc.queryForList("select * from `" + table + "`" + order); + List> canonical = rows.stream().map(row -> { + Map converted = new TreeMap<>(); + row.forEach((key,value) -> converted.put(key, value == null ? null + : value instanceof byte[] bytes ? Base64.getEncoder().encodeToString(bytes) : value.toString())); + return converted; + }).toList(); + result.put(table, canonical); + } + return result; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java new file mode 100644 index 0000000..1253314 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java @@ -0,0 +1,75 @@ +package com.mydreamjob.integration; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** 合并后的真实 MySQL 迁移、实体校验及运行时接口集合门禁。 */ +@Testcontainers(disabledWithoutDocker = false) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("mysql-it") +class LxqModulesMySqlIntegrationTest { + @Container static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("lxq_module_integration") + .withUsername("module_test").withPassword("module-test-only"); + @DynamicPropertySource static void properties(DynamicPropertyRegistry r) { + r.add("spring.datasource.url", MYSQL::getJdbcUrl); + r.add("spring.datasource.username", MYSQL::getUsername); + r.add("spring.datasource.password", MYSQL::getPassword); + } + @MockBean RedisTemplate redis; + @Autowired JdbcTemplate jdbc; + @Autowired MockMvc mvc; + @Autowired ObjectMapper mapper; + + @Test void migrationsAndSeedsAreRealAndComplete() { + assertThat(jdbc.queryForObject("select count(*) from flyway_schema_history where success=0", Long.class)).isZero(); + assertThat(jdbc.queryForObject("select count(*) from flyway_schema_history where version in ('47','48','49','50','51','52','53') and success=1", Long.class)).isEqualTo(7); + for (String table : Set.of("skills", "skill_industries", "skill_occupations", "occupation_skills", + "onboarding_checklist_items", "salary_city_policy")) { + assertThat(jdbc.queryForObject("select count(*) from " + table, Long.class)).as(table).isPositive(); + } + for (String table : Set.of("student_onboarding_progress", "company_knowledge", "salary_calculation_history")) { + assertThat(jdbc.queryForObject("select count(*) from " + table, Long.class)).as(table).isZero(); + } + } + + @Test void runtimeAndStaticOperationsAreIdentical() throws Exception { + 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(239).isEqualTo(operations(contract)); + assertThat(runtime.path("paths").size()).isEqualTo(196); + } + + private Set operations(JsonNode document) { + Set result = new HashSet<>(); + document.path("paths").fields().forEachRemaining(path -> + path.getValue().fieldNames().forEachRemaining(method -> { + if (Set.of("get", "post", "put", "patch", "delete", "head", "options", "trace").contains(method)) + result.add(method + " " + path.getKey()); + })); + return result; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqTestSessionIssuer.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqTestSessionIssuer.java new file mode 100644 index 0000000..65a37bd --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqTestSessionIssuer.java @@ -0,0 +1,39 @@ +package com.mydreamjob.integration; + +import com.mydreamjob.auth.AuthSessionService; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.audit.AuditContext; +import org.springframework.jdbc.core.JdbcTemplate; +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.Set; + +/** 整合测试使用真实数据库会话,不恢复旧版无会话 JWT 旁路。 */ +public final class LxqTestSessionIssuer { + private static final ThreadLocal> CREATED_USERS = ThreadLocal.withInitial(LinkedHashSet::new); + private static final ThreadLocal> SESSIONS = ThreadLocal.withInitial(LinkedHashSet::new); + private LxqTestSessionIssuer() {} + + public static String issue(long userId, String role, AuthSessionService sessions, + UserRepository users, JdbcTemplate jdbc) { + if (!users.existsById(userId)) { + jdbc.update("INSERT INTO users(id,phone,role,is_active,is_verified) VALUES(?,?,?,true,true)", + userId, "199" + String.format("%08d", userId), role); + jdbc.update("INSERT INTO user_roles(user_id,role) VALUES(?,?)", userId, role); + CREATED_USERS.get().add(userId); + } + var tokens = sessions.createSession(users.findById(userId).orElseThrow(), Instant.now(), new AuditContext()); + SESSIONS.get().add(tokens.sessionId()); + return tokens.accessToken(); + } + + public static void cleanup(JdbcTemplate jdbc) { + for (String id : SESSIONS.get()) jdbc.update("DELETE FROM auth_tokens WHERE session_id=?", id); + for (Long id : CREATED_USERS.get()) { + jdbc.update("DELETE FROM user_roles WHERE user_id=?", id); + jdbc.update("DELETE FROM users WHERE id=?", id); + } + SESSIONS.remove(); + CREATED_USERS.remove(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeControllerTest.java new file mode 100644 index 0000000..c0b476d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeControllerTest.java @@ -0,0 +1,233 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeView; +import com.mydreamjob.onboarding.dto.ReviewSummaryView; +import org.junit.jupiter.api.Test; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class CompanyKnowledgeControllerTest { + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.AuthSessionService integrationSessions; + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.UserRepository integrationUsers; + @org.springframework.beans.factory.annotation.Autowired private org.springframework.jdbc.core.JdbcTemplate integrationJdbc; + @org.junit.jupiter.api.AfterEach void cleanupIntegrationSessions() { + com.mydreamjob.integration.LxqTestSessionIssuer.cleanup(integrationJdbc); + } + + + private static final Long COMPANY_USER_ID = 900L; + private static final Long STUDENT_USER_ID = 901L; + private static final Long COMPANY_ID = 7L; + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @MockBean private CompanyKnowledgeService companyKnowledgeService; + @MockBean private RedisTemplate redisTemplate; + + @Test + void getCompanyKnowledge_shouldReturnUnconfiguredContract() throws Exception { + when(companyKnowledgeService.getCompanyKnowledge(COMPANY_USER_ID)) + .thenReturn(view(false, null, null, null, null, 0L)); + + mockMvc.perform(get("/onboarding/company/knowledge") + .header("Authorization", companyToken()) + .header("X-Request-Id", "req-t5-get")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.requestId").value("req-t5-get")) + .andExpect(jsonPath("$.data.configured").value(false)) + .andExpect(jsonPath("$.data.version").value(0)) + .andExpect(jsonPath("$.data.culture").doesNotExist()); + + verify(companyKnowledgeService).getCompanyKnowledge(COMPANY_USER_ID); + } + + @Test + void putCompanyKnowledge_shouldReturnConfiguredContract() throws Exception { + when(companyKnowledgeService.upsertCompanyKnowledge( + eq(COMPANY_USER_ID), any(CompanyKnowledgeUpdateRequest.class))) + .thenReturn(view(true, "Culture", "Policies", "Guide", + LocalDateTime.of(2026, 9, 16, 7, 0), 0L)); + + mockMvc.perform(put("/onboarding/company/knowledge") + .header("Authorization", companyToken()) + .header("X-Request-Id", "req-t5-put") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "culture": "Culture", + "workPolicies": "Policies", + "onboardingGuide": "Guide" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.requestId").value("req-t5-put")) + .andExpect(jsonPath("$.data.configured").value(true)) + .andExpect(jsonPath("$.data.culture").value("Culture")) + .andExpect(jsonPath("$.data.version").value(0)); + + verify(companyKnowledgeService).upsertCompanyKnowledge( + eq(COMPANY_USER_ID), argThat(request -> + "Culture".equals(request.getCulture()) + && "Policies".equals(request.getWorkPolicies()) + && "Guide".equals(request.getOnboardingGuide()) + && request.getVersion() == null)); + } + + @Test + void putCompanyKnowledge_withInvalidLength_shouldReturn400BeforeService() throws Exception { + String tooLong = "x".repeat(2001); + + mockMvc.perform(put("/onboarding/company/knowledge") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"culture":"%s"} + """.formatted(tooLong))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + verify(companyKnowledgeService, never()).upsertCompanyKnowledge(any(), any()); + } + + @Test + void putCompanyKnowledge_staleVersion_shouldReturn409() throws Exception { + when(companyKnowledgeService.upsertCompanyKnowledge( + eq(COMPANY_USER_ID), any(CompanyKnowledgeUpdateRequest.class))) + .thenThrow(new BusinessException( + ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE)); + + mockMvc.perform(put("/onboarding/company/knowledge") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"culture":"New","version":1} + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code") + .value(ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode())); + } + + @Test + void getCompanyKnowledge_asStudent_shouldReturn403() throws Exception { + when(companyKnowledgeService.getCompanyKnowledge(STUDENT_USER_ID)) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + + mockMvc.perform(get("/onboarding/company/knowledge") + .header("Authorization", studentToken())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); + } + + @Test + void studentOverview_shouldReturnWhitelistedCompanyAndReviewSummary() throws Exception { + when(companyKnowledgeService.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID)) + .thenReturn(overview()); + + mockMvc.perform(get("/onboarding/companies/{companyId}/knowledge", COMPANY_ID) + .header("Authorization", studentToken()) + .header("X-Request-Id", "req-t5-overview")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.requestId").value("req-t5-overview")) + .andExpect(jsonPath("$.data.knowledgeState").value("BASIC_ONLY")) + .andExpect(jsonPath("$.data.company.id").value(COMPANY_ID)) + .andExpect(jsonPath("$.data.company.name").value("Example Corp")) + .andExpect(jsonPath("$.data.company.website").value("https://example.com")) + .andExpect(jsonPath("$.data.company.address").value("Shanghai")) + .andExpect(jsonPath("$.data.company.ownerUserId").doesNotExist()) + .andExpect(jsonPath("$.data.reviewSummary.state").value("EMPTY")) + .andExpect(jsonPath("$.data.reviewSummary.totalReviews").value(0)); + + verify(companyKnowledgeService).getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID); + } + + @Test + void studentOverview_companyNotFound_shouldReturn404() throws Exception { + when(companyKnowledgeService.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID)) + .thenThrow(new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + + mockMvc.perform(get("/onboarding/companies/{companyId}/knowledge", COMPANY_ID) + .header("Authorization", studentToken())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND.getCode())); + } + + @Test + void getCompanyKnowledge_withoutToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/onboarding/company/knowledge")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + private CompanyKnowledgeView view( + boolean configured, + String culture, + String workPolicies, + String onboardingGuide, + LocalDateTime updatedAt, + Long version) { + return CompanyKnowledgeView.builder() + .configured(configured) + .culture(culture) + .workPolicies(workPolicies) + .onboardingGuide(onboardingGuide) + .updatedAt(updatedAt) + .version(version) + .build(); + } + + private CompanyKnowledgeOverviewResponse overview() { + return CompanyKnowledgeOverviewResponse.builder() + .company(CompanyKnowledgeOverviewResponse.CompanyView.builder() + .id(COMPANY_ID) + .name("Example Corp") + .logoUrl("https://example.com/logo.png") + .description("Description") + .industry("Software") + .website("https://example.com") + .address("Shanghai") + .verified(true) + .build()) + .knowledgeState("BASIC_ONLY") + .reviewSummary(ReviewSummaryView.builder() + .state("EMPTY") + .totalReviews(0) + .build()) + .build(); + } + + private String companyToken() { + return "Bearer " + com.mydreamjob.integration.LxqTestSessionIssuer.issue(COMPANY_USER_ID, "COMPANY", integrationSessions, integrationUsers, integrationJdbc); + } + + private String studentToken() { + return "Bearer " + com.mydreamjob.integration.LxqTestSessionIssuer.issue(STUDENT_USER_ID, "STUDENT", integrationSessions, integrationUsers, integrationJdbc); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java new file mode 100644 index 0000000..dbbc31c --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java @@ -0,0 +1,448 @@ +package com.mydreamjob.onboarding; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeView; +import com.mydreamjob.onboarding.dto.ReviewSummaryView; +import com.mydreamjob.review.JobReviewDimensionScoreRepository; +import com.mydreamjob.review.JobReviewRepository; +import com.mydreamjob.review.ReviewDimension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.orm.ObjectOptimisticLockingFailureException; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CompanyKnowledgeServiceTest { + + private static final Long COMPANY_USER_ID = 900L; + private static final Long STUDENT_USER_ID = 901L; + private static final Long COMPANY_ID = 7L; + private static final LocalDateTime UPDATED_AT = LocalDateTime.of(2026, 9, 16, 7, 0); + + @Mock private UserRepository userRepository; + @Mock private CompanyRepository companyRepository; + @Mock private CompanyKnowledgeRepository knowledgeRepository; + @Mock private JobReviewRepository reviewRepository; + @Mock private JobReviewDimensionScoreRepository dimensionScoreRepository; + @InjectMocks private CompanyKnowledgeService service; + + @Test + void getCompanyKnowledge_withoutRecord_shouldReturnUnconfiguredEmptyView() { + stubCompanyOwner(); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); + + CompanyKnowledgeView result = service.getCompanyKnowledge(COMPANY_USER_ID); + + assertThat(result.getConfigured()).isFalse(); + assertThat(result.getCulture()).isNull(); + assertThat(result.getWorkPolicies()).isNull(); + assertThat(result.getOnboardingGuide()).isNull(); + assertThat(result.getUpdatedAt()).isNull(); + assertThat(result.getVersion()).isZero(); + } + + @Test + void getCompanyKnowledge_withRecord_shouldReturnConfiguredView() { + stubCompanyOwner(); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of( + knowledge(1L, "Culture", "Policies", "Guide", 3L, UPDATED_AT))); + + CompanyKnowledgeView result = service.getCompanyKnowledge(COMPANY_USER_ID); + + assertThat(result.getConfigured()).isTrue(); + assertThat(result.getCulture()).isEqualTo("Culture"); + assertThat(result.getWorkPolicies()).isEqualTo("Policies"); + assertThat(result.getOnboardingGuide()).isEqualTo("Guide"); + assertThat(result.getUpdatedAt()).isEqualTo(UPDATED_AT); + assertThat(result.getVersion()).isEqualTo(3L); + } + + @Test + void upsertCompanyKnowledge_firstCreate_shouldTrimAndPersist() { + stubCompanyOwner(); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); + ArgumentCaptor captor = ArgumentCaptor.forClass(CompanyKnowledge.class); + when(knowledgeRepository.saveAndFlush(any(CompanyKnowledge.class))).thenAnswer(invocation -> { + CompanyKnowledge saved = invocation.getArgument(0); + saved.setId(11L); + saved.setVersion(0L); + saved.setUpdatedAt(UPDATED_AT); + return saved; + }); + + CompanyKnowledgeView result = service.upsertCompanyKnowledge( + COMPANY_USER_ID, + request(" Culture ", " Policies ", " Guide ", null)); + + verify(knowledgeRepository).saveAndFlush(captor.capture()); + CompanyKnowledge saved = captor.getValue(); + assertThat(saved.getCompanyId()).isEqualTo(COMPANY_ID); + assertThat(saved.getCulture()).isEqualTo("Culture"); + assertThat(saved.getWorkPolicies()).isEqualTo("Policies"); + assertThat(saved.getOnboardingGuide()).isEqualTo("Guide"); + assertThat(result.getConfigured()).isTrue(); + assertThat(result.getVersion()).isZero(); + } + + @Test + void upsertCompanyKnowledge_updateWithCurrentVersion_shouldIncrementVersion() { + stubCompanyOwner(); + CompanyKnowledge existing = knowledge(12L, "Old culture", null, null, 2L, UPDATED_AT); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of(existing)); + when(knowledgeRepository.saveAndFlush(existing)).thenAnswer(invocation -> { + existing.setVersion(3L); + existing.setUpdatedAt(UPDATED_AT.plusHours(1)); + return existing; + }); + + CompanyKnowledgeView result = service.upsertCompanyKnowledge( + COMPANY_USER_ID, request("New culture", null, "New guide", 2L)); + + assertThat(existing.getCulture()).isEqualTo("New culture"); + assertThat(existing.getWorkPolicies()).isNull(); + assertThat(existing.getOnboardingGuide()).isEqualTo("New guide"); + assertThat(result.getVersion()).isEqualTo(3L); + } + + @Test + void upsertCompanyKnowledge_existingWithoutVersion_shouldReturnBadRequest() { + stubCompanyOwner(); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of( + knowledge(1L, "culture", null, null, 1L, UPDATED_AT))); + + assertThatThrownBy(() -> service.upsertCompanyKnowledge( + COMPANY_USER_ID, request("new", null, null, null))) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.BAD_REQUEST.getCode()); + verify(knowledgeRepository, never()).saveAndFlush(any()); + } + + @Test + void upsertCompanyKnowledge_staleVersion_shouldReturnKnowledgeConflict() { + stubCompanyOwner(); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of( + knowledge(1L, "culture", null, null, 4L, UPDATED_AT))); + + assertThatThrownBy(() -> service.upsertCompanyKnowledge( + COMPANY_USER_ID, request("new", null, null, 3L))) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode()); + verify(knowledgeRepository, never()).saveAndFlush(any()); + } + + @Test + void upsertCompanyKnowledge_optimisticLockFailure_shouldReturnKnowledgeConflict() { + stubCompanyOwner(); + CompanyKnowledge existing = knowledge(1L, "culture", null, null, 1L, UPDATED_AT); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of(existing)); + when(knowledgeRepository.saveAndFlush(existing)) + .thenThrow(new ObjectOptimisticLockingFailureException(CompanyKnowledge.class, 1L)); + + assertThatThrownBy(() -> service.upsertCompanyKnowledge( + COMPANY_USER_ID, request("new", null, null, 1L))) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode()); + } + + @Test + void upsertCompanyKnowledge_invalidPlainTextFields_shouldReturnBadRequest() { + stubCompanyOwner(); + List invalid = List.of( + request(null, null, null, null), + request(" ", " ", " ", null), + request("x".repeat(2001), null, null, null), + request(null, "x".repeat(4001), null, null), + request(null, null, "x".repeat(4001), null), + request("", null, null, null)); + + for (CompanyKnowledgeUpdateRequest value : invalid) { + assertThatThrownBy(() -> service.upsertCompanyKnowledge(COMPANY_USER_ID, value)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.BAD_REQUEST.getCode()); + } + verifyNoInteractions(knowledgeRepository); + } + + @Test + void overview_withoutKnowledge_shouldReturnBasicOnlyAndEmptyReviewSummary() { + stubStudent(); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); + when(reviewRepository.countAndAveragePublishedRatingByCompanyId(COMPANY_ID)) + .thenReturn(List.of(new Object[]{0L, BigDecimal.ZERO})); + + CompanyKnowledgeOverviewResponse result = + service.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID); + + assertThat(result.getKnowledgeState()).isEqualTo("BASIC_ONLY"); + assertThat(result.getKnowledge()).isNull(); + assertThat(result.getCompany().getId()).isEqualTo(COMPANY_ID); + assertThat(result.getCompany().getWebsite()).isEqualTo("https://example.com"); + assertThat(result.getCompany().getAddress()).isEqualTo("Shanghai"); + assertThat(result.getReviewSummary().getState()).isEqualTo("EMPTY"); + assertThat(result.getReviewSummary().getTotalReviews()).isZero(); + assertThat(result.getReviewSummary().getAverageRating()).isNull(); + assertThat(result.getReviewSummary().getDimensionSummary()).isNull(); + } + + @Test + void overview_withKnowledge_shouldReturnConfiguredKnowledge() { + stubStudent(); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of( + knowledge(5L, "Culture", "Policies", "Guide", 2L, UPDATED_AT))); + when(reviewRepository.countAndAveragePublishedRatingByCompanyId(COMPANY_ID)) + .thenReturn(List.of(new Object[]{1L, 4.0})); + + CompanyKnowledgeOverviewResponse result = + service.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID); + + assertThat(result.getKnowledgeState()).isEqualTo("CONFIGURED"); + assertThat(result.getKnowledge().getCulture()).isEqualTo("Culture"); + assertThat(result.getKnowledge().getWorkPolicies()).isEqualTo("Policies"); + assertThat(result.getKnowledge().getOnboardingGuide()).isEqualTo("Guide"); + assertThat(result.getKnowledge().getUpdatedAt()).isEqualTo(UPDATED_AT); + assertThat(result.getKnowledge().getVersion()).isNull(); + assertThat(result.getReviewSummary().getState()).isEqualTo("INSUFFICIENT"); + assertThat(result.getReviewSummary().getTotalReviews()).isEqualTo(1); + verify(reviewRepository, never()).findPublishedRatingDistributionByCompanyId(any()); + } + + @Test + void overview_withThreePublicReviews_shouldReturnAggregatesAndAllEightDimensions() { + stubStudent(); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); + when(reviewRepository.countAndAveragePublishedRatingByCompanyId(COMPANY_ID)) + .thenReturn(List.of(new Object[]{3L, 4.333})); + when(reviewRepository.findPublishedRatingDistributionByCompanyId(COMPANY_ID)) + .thenReturn(List.of( + new Object[]{3, 1L}, + new Object[]{4, 1L}, + new Object[]{5, 1L})); + when(reviewRepository.findPublishedTagsByCompanyId(COMPANY_ID)) + .thenReturn(List.of( + "[\"beta\",\"team\"]", + "[\"alpha\",\"beta\"]")); + when(dimensionScoreRepository.aggregatePublishedDimensionsByCompanyId(COMPANY_ID)) + .thenReturn(List.of( + new Object[]{"TEAM_ATMOSPHERE", 4.0, 3L}, + new Object[]{"GROWTH", 4.5, 2L})); + + CompanyKnowledgeOverviewResponse result = + service.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID); + ReviewSummaryView summary = result.getReviewSummary(); + + assertThat(summary.getState()).isEqualTo("SUFFICIENT"); + assertThat(summary.getTotalReviews()).isEqualTo(3); + assertThat(summary.getAverageRating()).isEqualTo(4.3); + assertThat(summary.getRatingDistribution()) + .containsEntry("rating1", 0) + .containsEntry("rating2", 0) + .containsEntry("rating3", 1) + .containsEntry("rating4", 1) + .containsEntry("rating5", 1); + assertThat(summary.getTopTags()).containsExactly("beta", "alpha", "team"); + assertThat(summary.getDimensionSummary()).hasSize(ReviewDimension.values().length); + assertThat(summary.getDimensionSummary().get("TEAM_ATMOSPHERE").getAverageScore()) + .isEqualTo(4.0); + assertThat(summary.getDimensionSummary().get("TEAM_ATMOSPHERE").getSampleCount()) + .isEqualTo(3L); + assertThat(summary.getDimensionSummary().get("TEAM_ATMOSPHERE").getDataState()) + .isEqualTo("SUFFICIENT"); + assertThat(summary.getDimensionSummary().get("GROWTH").getDataState()) + .isEqualTo("INSUFFICIENT"); + assertThat(summary.getDimensionSummary().get("AUTHENTICITY").getSampleCount()).isZero(); + assertThat(summary.getDimensionSummary().get("AUTHENTICITY").getAverageScore()).isNull(); + assertThat(summary.getDimensionSummary().get("AUTHENTICITY").getDataState()) + .isEqualTo("INSUFFICIENT"); + } + + @Test + void overview_withExactlyTwoPublicReviews_shouldReturnInsufficientWithoutAggregates() { + stubStudent(); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); + when(reviewRepository.countAndAveragePublishedRatingByCompanyId(COMPANY_ID)) + .thenReturn(List.of(new Object[]{2L, BigDecimal.valueOf(4.0)})); + + ReviewSummaryView summary = service.getCompanyKnowledgeOverview( + STUDENT_USER_ID, COMPANY_ID).getReviewSummary(); + + assertThat(summary.getState()).isEqualTo("INSUFFICIENT"); + assertThat(summary.getTotalReviews()).isEqualTo(2); + assertThat(summary.getAverageRating()).isNull(); + assertThat(summary.getRatingDistribution()).isNull(); + assertThat(summary.getTopTags()).isNull(); + assertThat(summary.getDimensionSummary()).isNull(); + verify(reviewRepository, never()).findPublishedRatingDistributionByCompanyId(any()); + verify(reviewRepository, never()).findPublishedTagsByCompanyId(any()); + verifyNoInteractions(dimensionScoreRepository); + } + + @Test + void overview_withMoreThanTenDistinctTags_shouldReturnOnlyTopTenDeterministically() { + stubStudent(); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); + when(reviewRepository.countAndAveragePublishedRatingByCompanyId(COMPANY_ID)) + .thenReturn(List.of(new Object[]{3L, BigDecimal.valueOf(4.0)})); + when(reviewRepository.findPublishedRatingDistributionByCompanyId(COMPANY_ID)) + .thenReturn(List.of()); + List rawTags = new ArrayList<>(); + for (int index = 1; index <= 11; index++) { + rawTags.add("[\"tag-" + String.format("%02d", index) + "\"]"); + } + when(reviewRepository.findPublishedTagsByCompanyId(COMPANY_ID)).thenReturn(rawTags); + when(dimensionScoreRepository.aggregatePublishedDimensionsByCompanyId(COMPANY_ID)) + .thenReturn(List.of()); + + ReviewSummaryView summary = service.getCompanyKnowledgeOverview( + STUDENT_USER_ID, COMPANY_ID).getReviewSummary(); + + assertThat(summary.getTopTags()).containsExactly( + "tag-01", "tag-02", "tag-03", "tag-04", "tag-05", + "tag-06", "tag-07", "tag-08", "tag-09", "tag-10"); + } + + @Test + void overview_withRecursivelyQuotedJsonTags_shouldParseTheTags() throws Exception { + stubStudent(); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); + when(reviewRepository.countAndAveragePublishedRatingByCompanyId(COMPANY_ID)) + .thenReturn(List.of(new Object[]{3L, BigDecimal.valueOf(4.0)})); + when(reviewRepository.findPublishedRatingDistributionByCompanyId(COMPANY_ID)) + .thenReturn(List.of()); + ObjectMapper mapper = new ObjectMapper(); + String onceQuoted = mapper.writeValueAsString("[\"nested-tag\"]"); + String twiceQuoted = mapper.writeValueAsString(onceQuoted); + when(reviewRepository.findPublishedTagsByCompanyId(COMPANY_ID)) + .thenReturn(List.of(twiceQuoted)); + when(dimensionScoreRepository.aggregatePublishedDimensionsByCompanyId(COMPANY_ID)) + .thenReturn(List.of()); + + ReviewSummaryView summary = service.getCompanyKnowledgeOverview( + STUDENT_USER_ID, COMPANY_ID).getReviewSummary(); + + assertThat(summary.getTopTags()).containsExactly("nested-tag"); + } + + @Test + void overview_companyNotFound_shouldReturnCompanyNotFound() { + stubStudent(); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.COMPANY_NOT_FOUND.getCode()); + } + + @Test + void overview_withNonStudentRole_shouldReturnForbidden() { + when(userRepository.findById(STUDENT_USER_ID)).thenReturn(Optional.of( + User.builder().id(STUDENT_USER_ID).roles(Set.of(User.Role.COMPANY)).build())); + + assertThatThrownBy(() -> service.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.FORBIDDEN.getCode()); + verifyNoInteractions(companyRepository, knowledgeRepository); + } + + @Test + void companyKnowledge_withNonCompanyRole_shouldReturnForbidden() { + when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of( + User.builder().id(COMPANY_USER_ID).roles(Set.of(User.Role.STUDENT)).build())); + + assertThatThrownBy(() -> service.getCompanyKnowledge(COMPANY_USER_ID)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.FORBIDDEN.getCode()); + verifyNoInteractions(companyRepository, knowledgeRepository); + } + + private void stubCompanyOwner() { + when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of( + User.builder().id(COMPANY_USER_ID).roles(Set.of(User.Role.COMPANY)).build())); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.of(company())); + } + + private void stubStudent() { + when(userRepository.findById(STUDENT_USER_ID)).thenReturn(Optional.of( + User.builder().id(STUDENT_USER_ID).roles(Set.of(User.Role.STUDENT)).build())); + } + + private Company company() { + return Company.builder() + .id(COMPANY_ID) + .ownerUserId(COMPANY_USER_ID) + .name("Example Corp") + .logoUrl("https://example.com/logo.png") + .description("Description") + .industry("Software") + .website("https://example.com") + .address("Shanghai") + .verified(true) + .build(); + } + + private CompanyKnowledge knowledge( + Long id, + String culture, + String workPolicies, + String onboardingGuide, + Long version, + LocalDateTime updatedAt) { + return CompanyKnowledge.builder() + .id(id) + .companyId(COMPANY_ID) + .culture(culture) + .workPolicies(workPolicies) + .onboardingGuide(onboardingGuide) + .version(version) + .updatedAt(updatedAt) + .build(); + } + + private CompanyKnowledgeUpdateRequest request( + String culture, String workPolicies, String onboardingGuide, Long version) { + CompanyKnowledgeUpdateRequest request = new CompanyKnowledgeUpdateRequest(); + request.setCulture(culture); + request.setWorkPolicies(workPolicies); + request.setOnboardingGuide(onboardingGuide); + request.setVersion(version); + return request; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingAuthorizationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingAuthorizationIntegrationTest.java new file mode 100644 index 0000000..9f73fca --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingAuthorizationIntegrationTest.java @@ -0,0 +1,217 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:onboarding_authorization;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class OnboardingAuthorizationIntegrationTest { + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.AuthSessionService integrationSessions; + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.UserRepository integrationUsers; + @org.springframework.beans.factory.annotation.Autowired private org.springframework.jdbc.core.JdbcTemplate integrationJdbc; + @org.junit.jupiter.api.AfterEach void cleanupIntegrationSessions() { + com.mydreamjob.integration.LxqTestSessionIssuer.cleanup(integrationJdbc); + } + + + private static final AtomicLong PHONE_SEQ = new AtomicLong(971_000_000L); + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private OnboardingChecklistItemRepository checklistItemRepository; + @Autowired private StudentOnboardingProgressRepository progressRepository; + @Autowired private CompanyKnowledgeRepository knowledgeRepository; + @Autowired private OnboardingChecklistService checklistService; + + @MockBean private RedisTemplate redisTemplate; + + @BeforeEach + void clearData() { + progressRepository.deleteAll(); + knowledgeRepository.deleteAll(); + checklistItemRepository.deleteAll(); + companyRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + void anonymousOnboardingRequestsAreUnauthorized() throws Exception { + mockMvc.perform(get("/onboarding/companies/1/checklist")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + mockMvc.perform(get("/onboarding/company/checklist-items")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + mockMvc.perform(get("/onboarding/company/knowledge")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void onboardingEndpointsRejectWrongRolesWithForbidden() throws Exception { + Long companyUser = createUser(User.Role.COMPANY); + createCompany(companyUser, "权限企业"); + Long student = createUser(User.Role.STUDENT); + Long admin = createUser(User.Role.ADMIN); + + mockMvc.perform(get("/onboarding/companies/1/checklist") + .header("Authorization", bearer(companyUser))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); + + for (Long userId : java.util.List.of(student, admin)) { + mockMvc.perform(get("/onboarding/company/checklist-items") + .header("Authorization", bearer(userId))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); + } + } + + @Test + void companyCannotUpdateOrDeleteAnotherCompanysItem() { + Long companyAUser = createUser(User.Role.COMPANY); + Long companyA = createCompany(companyAUser, "企业A"); + Long companyBUser = createUser(User.Role.COMPANY); + createCompany(companyBUser, "企业B"); + OnboardingChecklistItem item = checklistItemRepository.save(OnboardingChecklistItem.builder() + .scopeType("COMPANY") + .scopeId(companyA) + .companyId(companyA) + .itemKey("company-a-secret") + .title("A 企业专属标题") + .category("ADMIN") + .sortOrder(100) + .required(false) + .active(true) + .build()); + + CompanyChecklistItemUpdateRequest update = new CompanyChecklistItemUpdateRequest(); + update.setTitle("越权修改"); + update.setCategory("ADMIN"); + update.setSortOrder(100); + update.setRequired(false); + update.setActive(true); + update.setVersion(0L); + + assertThatThrownBy(() -> checklistService.updateCompanyChecklistItem( + companyBUser, item.getId(), update)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode()); + assertThatThrownBy(() -> checklistService.deleteCompanyChecklistItem( + companyBUser, item.getId(), 0L)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode()); + + OnboardingChecklistItem unchanged = checklistItemRepository.findById(item.getId()).orElseThrow(); + assertThat(unchanged.getTitle()).isEqualTo("A 企业专属标题"); + assertThat(unchanged.getActive()).isTrue(); + } + + @Test + void httpCrossCompanyUpdateReturnsNotFoundWithoutChangingTarget() throws Exception { + Long companyAUser = createUser(User.Role.COMPANY); + Long companyA = createCompany(companyAUser, "HTTP 企业A"); + Long companyBUser = createUser(User.Role.COMPANY); + createCompany(companyBUser, "HTTP 企业B"); + OnboardingChecklistItem item = checklistItemRepository.save(OnboardingChecklistItem.builder() + .scopeType("COMPANY") + .scopeId(companyA) + .companyId(companyA) + .itemKey("http-company-a") + .title("HTTP 企业专属") + .category("ADMIN") + .sortOrder(100) + .required(false) + .active(true) + .build()); + + mockMvc.perform(put("/onboarding/company/checklist-items/{itemId}", item.getId()) + .header("Authorization", bearer(companyBUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "越权修改", + "category": "ADMIN", + "sortOrder": 100, + "required": false, + "active": true, + "version": 0 + } + """)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code") + .value(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode())); + + assertThat(checklistItemRepository.findById(item.getId()).orElseThrow().getTitle()) + .isEqualTo("HTTP 企业专属"); + } + + @Test + void companyWithoutProfileReturnsCompanyNotFound() throws Exception { + Long companyUser = createUser(User.Role.COMPANY); + + mockMvc.perform(get("/onboarding/company/checklist-items") + .header("Authorization", bearer(companyUser))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND.getCode())); + } + + private Long createCompany(Long ownerUserId, String name) { + return companyRepository.save(Company.builder() + .ownerUserId(ownerUserId) + .name(name) + .verified(true) + .build()).getId(); + } + + private Long createUser(User.Role role) { + User user = userRepository.save(User.builder() + .phone(String.valueOf(PHONE_SEQ.getAndIncrement())) + .nickname(role.name()) + .role(role) + .roles(Set.of(role)) + .active(true) + .verified(true) + .build()); + return user.getId(); + } + + private String bearer(Long userId) { + return "Bearer " + com.mydreamjob.integration.LxqTestSessionIssuer.issue(userId, "STUDENT", integrationSessions, integrationUsers, integrationJdbc); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistControllerTest.java new file mode 100644 index 0000000..c04a21c --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistControllerTest.java @@ -0,0 +1,172 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.onboarding.dto.ChecklistItemView; +import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse; +import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; +import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; +import org.junit.jupiter.api.Test; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class OnboardingChecklistControllerTest { + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.AuthSessionService integrationSessions; + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.UserRepository integrationUsers; + @org.springframework.beans.factory.annotation.Autowired private org.springframework.jdbc.core.JdbcTemplate integrationJdbc; + @org.junit.jupiter.api.AfterEach void cleanupIntegrationSessions() { + com.mydreamjob.integration.LxqTestSessionIssuer.cleanup(integrationJdbc); + } + + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @MockBean private OnboardingChecklistService onboardingChecklistService; + @MockBean private RedisTemplate redisTemplate; + + @Test + void getChecklist_shouldReturn200WithStableProgressContract() throws Exception { + when(onboardingChecklistService.getEffectiveChecklist(100L, 7L)) + .thenReturn(effectiveChecklistResponse()); + + mockMvc.perform(get("/onboarding/companies/7/checklist") + .header("Authorization", token()) + .header("X-Request-Id", "req-t3-get")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.requestId").value("req-t3-get")) + .andExpect(jsonPath("$.data.companyId").value(7)) + .andExpect(jsonPath("$.data.companyName").value("示例企业")) + .andExpect(jsonPath("$.data.customized").value(true)) + .andExpect(jsonPath("$.data.totalItems").value(1)) + .andExpect(jsonPath("$.data.completedItems").value(1)) + .andExpect(jsonPath("$.data.completionRate").value(100.0)) + .andExpect(jsonPath("$.data.items[0].itemId").value(11)) + .andExpect(jsonPath("$.data.items[0].source").value("STANDARD")) + .andExpect(jsonPath("$.data.items[0].progressVersion").value(2)); + + verify(onboardingChecklistService).getEffectiveChecklist(100L, 7L); + } + + @Test + void updateCompletion_shouldReturn200WithRequestIdentityAndLatestRate() throws Exception { + when(onboardingChecklistService.updateCompletion( + eq(100L), eq(7L), eq(11L), any(ProgressUpdateRequest.class))) + .thenReturn(ProgressUpdateResponse.builder() + .itemId(11L) + .completed(true) + .completedAt(LocalDateTime.of(2026, 9, 16, 10, 30)) + .progressVersion(0L) + .totalItems(1) + .completedItems(1) + .completionRate(100.0) + .build()); + + mockMvc.perform(put("/onboarding/companies/7/checklist/items/11/completion") + .header("Authorization", token()) + .header("X-Request-Id", "req-t3-put") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"completed\":true,\"version\":0}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.requestId").value("req-t3-put")) + .andExpect(jsonPath("$.data.itemId").value(11)) + .andExpect(jsonPath("$.data.completed").value(true)) + .andExpect(jsonPath("$.data.progressVersion").value(0)) + .andExpect(jsonPath("$.data.totalItems").value(1)) + .andExpect(jsonPath("$.data.completedItems").value(1)) + .andExpect(jsonPath("$.data.completionRate").value(100.0)); + + verify(onboardingChecklistService).updateCompletion( + eq(100L), + eq(7L), + eq(11L), + argThat(request -> Boolean.TRUE.equals(request.getCompleted()) + && Long.valueOf(0L).equals(request.getVersion()))); + } + + @Test + void getChecklist_withoutToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/onboarding/companies/7/checklist")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void getChecklist_nonStudent_shouldReturn403() throws Exception { + when(onboardingChecklistService.getEffectiveChecklist(100L, 7L)) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + + mockMvc.perform(get("/onboarding/companies/7/checklist") + .header("Authorization", token())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); + } + + @Test + void updateCompletion_companyMissing_shouldReturn404() throws Exception { + when(onboardingChecklistService.updateCompletion( + eq(100L), eq(7L), eq(11L), any(ProgressUpdateRequest.class))) + .thenThrow(new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + + mockMvc.perform(put("/onboarding/companies/7/checklist/items/11/completion") + .header("Authorization", token()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"completed\":true,\"version\":0}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND.getCode())); + } + + private String token() { + return "Bearer " + com.mydreamjob.integration.LxqTestSessionIssuer.issue(100L, "STUDENT", integrationSessions, integrationUsers, integrationJdbc); + } + + private EffectiveChecklistResponse effectiveChecklistResponse() { + return EffectiveChecklistResponse.builder() + .companyId(7L) + .companyName("示例企业") + .customized(true) + .totalItems(1) + .completedItems(1) + .completionRate(100.0) + .items(List.of(ChecklistItemView.builder() + .itemId(11L) + .source("STANDARD") + .itemKey("standard-onboard") + .title("准备入职材料") + .description("身份证和银行卡") + .category("DOCUMENT") + .required(true) + .sortOrder(10) + .active(true) + .completed(true) + .completedAt(LocalDateTime.of(2026, 9, 16, 10, 30)) + .progressVersion(2L) + .build())) + .build(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistServiceTest.java new file mode 100644 index 0000000..209d121 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistServiceTest.java @@ -0,0 +1,305 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse; +import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; +import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OnboardingChecklistServiceTest { + + private static final Long STUDENT_ID = 100L; + private static final Long COMPANY_ID = 7L; + private static final LocalDateTime COMPLETED_AT = LocalDateTime.of(2026, 9, 16, 10, 30); + + @Mock private UserRepository userRepository; + @Mock private CompanyRepository companyRepository; + @Mock private OnboardingChecklistItemRepository checklistItemRepository; + @Mock private StudentOnboardingProgressRepository progressRepository; + @InjectMocks private OnboardingChecklistService service; + + @Test + void getEffectiveChecklist_withoutCompanyItems_shouldReturnStandardFallback() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student())); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + OnboardingChecklistItem standard = item(1L, "STANDARD", null, "standard-onboard", 10); + when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID)) + .thenReturn(List.of(standard)); + when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection())) + .thenReturn(List.of()); + + EffectiveChecklistResponse response = service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID); + + assertThat(response.getCompanyId()).isEqualTo(COMPANY_ID); + assertThat(response.getCompanyName()).isEqualTo("示例企业"); + assertThat(response.getCustomized()).isFalse(); + assertThat(response.getTotalItems()).isEqualTo(1); + assertThat(response.getCompletedItems()).isZero(); + assertThat(response.getCompletionRate()).isEqualTo(0.0); + assertThat(response.getItems()).singleElement().satisfies(view -> { + assertThat(view.getItemId()).isEqualTo(1L); + assertThat(view.getSource()).isEqualTo("STANDARD"); + assertThat(view.getItemKey()).isEqualTo("standard-onboard"); + assertThat(view.getCompleted()).isFalse(); + assertThat(view.getCompletedAt()).isNull(); + assertThat(view.getProgressVersion()).isZero(); + }); + } + + @Test + void getEffectiveChecklist_withCompanyItems_shouldMergeSortAndComputeProgress() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student())); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + OnboardingChecklistItem standardSecond = item(2L, "STANDARD", null, "standard-second", 10); + OnboardingChecklistItem companyFirst = item(3L, "COMPANY", COMPANY_ID, "company-first", 10); + OnboardingChecklistItem standardThird = item(1L, "STANDARD", null, "standard-third", 20); + when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID)) + .thenReturn(List.of(standardThird, companyFirst, standardSecond)); + + StudentOnboardingProgress completed = progress(3L, true, COMPLETED_AT, 4L); + StudentOnboardingProgress orphaned = progress(99L, true, COMPLETED_AT, 8L); + when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection())) + .thenReturn(List.of(completed, orphaned)); + + EffectiveChecklistResponse response = service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID); + + assertThat(response.getCustomized()).isTrue(); + assertThat(response.getTotalItems()).isEqualTo(3); + assertThat(response.getCompletedItems()).isEqualTo(1); + assertThat(response.getCompletionRate()).isEqualTo(33.3); + assertThat(response.getItems()).extracting(view -> view.getItemId()) + .containsExactly(2L, 3L, 1L); + assertThat(response.getItems().get(1).getSource()).isEqualTo("COMPANY"); + assertThat(response.getItems().get(1).getCompleted()).isTrue(); + assertThat(response.getItems().get(1).getCompletedAt()).isEqualTo(COMPLETED_AT); + assertThat(response.getItems().get(1).getProgressVersion()).isEqualTo(4L); + } + + @Test + void getEffectiveChecklist_empty_shouldReturnZeroRate() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student())); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID)).thenReturn(List.of()); + + EffectiveChecklistResponse response = service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID); + + assertThat(response.getItems()).isEmpty(); + assertThat(response.getTotalItems()).isZero(); + assertThat(response.getCompletedItems()).isZero(); + assertThat(response.getCompletionRate()).isEqualTo(0.0); + assertThat(response.getCustomized()).isFalse(); + } + + @Test + void getEffectiveChecklist_companyMissing_shouldThrowCompanyNotFound() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student())); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.COMPANY_NOT_FOUND.getCode()); + verifyNoInteractions(checklistItemRepository, progressRepository); + } + + @Test + void getEffectiveChecklist_nonStudent_shouldThrowForbidden() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of( + User.builder().id(STUDENT_ID).roles(Set.of(User.Role.COMPANY)).build())); + + assertThatThrownBy(() -> service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.FORBIDDEN.getCode()); + verifyNoInteractions(companyRepository, checklistItemRepository, progressRepository); + } + + @Test + void updateCompletion_firstCompletion_shouldCreateProgressAndReturnRate() { + stubAccessAndOneItem(); + when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection())) + .thenReturn(new ArrayList<>()); + when(progressRepository.findByStudentUserIdAndItemId(STUDENT_ID, 11L)) + .thenReturn(Optional.empty()); + when(progressRepository.saveAndFlush(any(StudentOnboardingProgress.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + ProgressUpdateResponse response = service.updateCompletion( + STUDENT_ID, COMPANY_ID, 11L, request(true, 0L)); + + assertThat(response.getItemId()).isEqualTo(11L); + assertThat(response.getCompleted()).isTrue(); + assertThat(response.getCompletedAt()).isNotNull(); + assertThat(response.getProgressVersion()).isZero(); + assertThat(response.getCompletedItems()).isEqualTo(1); + assertThat(response.getTotalItems()).isEqualTo(1); + assertThat(response.getCompletionRate()).isEqualTo(100.0); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(StudentOnboardingProgress.class); + verify(progressRepository).saveAndFlush(captor.capture()); + assertThat(captor.getValue().getStudentUserId()).isEqualTo(STUDENT_ID); + assertThat(captor.getValue().getItemId()).isEqualTo(11L); + assertThat(captor.getValue().getCompleted()).isTrue(); + assertThat(captor.getValue().getCompletedAt()).isNotNull(); + } + + @Test + void updateCompletion_sameStateWithStaleVersion_shouldBeIdempotent() { + stubAccessAndOneItem(); + StudentOnboardingProgress existing = progress(11L, true, COMPLETED_AT, 5L); + when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection())) + .thenReturn(List.of(existing)); + when(progressRepository.findByStudentUserIdAndItemId(STUDENT_ID, 11L)) + .thenReturn(Optional.of(existing)); + + ProgressUpdateResponse response = service.updateCompletion( + STUDENT_ID, COMPANY_ID, 11L, request(true, 2L)); + + assertThat(response.getCompleted()).isTrue(); + assertThat(response.getCompletedAt()).isEqualTo(COMPLETED_AT); + assertThat(response.getProgressVersion()).isEqualTo(5L); + assertThat(response.getCompletionRate()).isEqualTo(100.0); + verify(progressRepository, never()).saveAndFlush(any()); + } + + @Test + void updateCompletion_differentStateWithStaleVersion_shouldReturnConcurrentUpdate() { + stubAccessAndOneItem(); + StudentOnboardingProgress existing = progress(11L, true, COMPLETED_AT, 5L); + when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection())) + .thenReturn(List.of(existing)); + when(progressRepository.findByStudentUserIdAndItemId(STUDENT_ID, 11L)) + .thenReturn(Optional.of(existing)); + + assertThatThrownBy(() -> service.updateCompletion( + STUDENT_ID, COMPANY_ID, 11L, request(false, 4L))) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode()); + assertThat(existing.getCompleted()).isTrue(); + assertThat(existing.getCompletedAt()).isEqualTo(COMPLETED_AT); + verify(progressRepository, never()).saveAndFlush(any()); + } + + @Test + void updateCompletion_cancel_shouldClearCompletedAt() { + stubAccessAndOneItem(); + StudentOnboardingProgress existing = progress(11L, true, COMPLETED_AT, 5L); + when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection())) + .thenReturn(List.of(existing)); + when(progressRepository.findByStudentUserIdAndItemId(STUDENT_ID, 11L)) + .thenReturn(Optional.of(existing)); + when(progressRepository.saveAndFlush(any(StudentOnboardingProgress.class))) + .thenAnswer(invocation -> { + StudentOnboardingProgress saved = invocation.getArgument(0); + saved.setVersion(6L); + return saved; + }); + + ProgressUpdateResponse response = service.updateCompletion( + STUDENT_ID, COMPANY_ID, 11L, request(false, 5L)); + + assertThat(response.getCompleted()).isFalse(); + assertThat(response.getCompletedAt()).isNull(); + assertThat(response.getProgressVersion()).isEqualTo(6L); + assertThat(response.getCompletedItems()).isZero(); + assertThat(response.getCompletionRate()).isEqualTo(0.0); + assertThat(existing.getCompleted()).isFalse(); + assertThat(existing.getCompletedAt()).isNull(); + verify(progressRepository).saveAndFlush(existing); + } + + @Test + void updateCompletion_itemOutsideCompanyEffectiveChecklist_shouldReturnNotFound() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student())); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID)) + .thenReturn(List.of(item(11L, "COMPANY", COMPANY_ID, "company-item", 10))); + + assertThatThrownBy(() -> service.updateCompletion( + STUDENT_ID, COMPANY_ID, 999L, request(true, 0L))) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode()); + verifyNoInteractions(progressRepository); + } + + private void stubAccessAndOneItem() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student())); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID)) + .thenReturn(List.of(item(11L, "STANDARD", null, "standard-onboard", 10))); + } + + private User student() { + return User.builder().id(STUDENT_ID).roles(Set.of(User.Role.STUDENT)).build(); + } + + private Company company() { + return Company.builder().id(COMPANY_ID).name("示例企业").ownerUserId(900L).build(); + } + + private OnboardingChecklistItem item( + Long id, String scopeType, Long companyId, String itemKey, int sortOrder) { + return OnboardingChecklistItem.builder() + .id(id) + .scopeType(scopeType) + .scopeId(companyId == null ? 0L : companyId) + .companyId(companyId) + .itemKey(itemKey) + .title(itemKey + "-title") + .description(itemKey + "-description") + .category("OTHER") + .sortOrder(sortOrder) + .required(false) + .active(true) + .build(); + } + + private StudentOnboardingProgress progress( + Long itemId, boolean completed, LocalDateTime completedAt, Long version) { + return StudentOnboardingProgress.builder() + .id(itemId * 10) + .studentUserId(STUDENT_ID) + .itemId(itemId) + .completed(completed) + .completedAt(completedAt) + .version(version) + .build(); + } + + private ProgressUpdateRequest request(boolean completed, Long version) { + ProgressUpdateRequest request = new ProgressUpdateRequest(); + request.setCompleted(completed); + request.setVersion(version); + return request; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistControllerTest.java new file mode 100644 index 0000000..4dbb423 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistControllerTest.java @@ -0,0 +1,282 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemCreateRequest; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemView; +import org.junit.jupiter.api.Test; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class OnboardingCompanyChecklistControllerTest { + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.AuthSessionService integrationSessions; + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.UserRepository integrationUsers; + @org.springframework.beans.factory.annotation.Autowired private org.springframework.jdbc.core.JdbcTemplate integrationJdbc; + @org.junit.jupiter.api.AfterEach void cleanupIntegrationSessions() { + com.mydreamjob.integration.LxqTestSessionIssuer.cleanup(integrationJdbc); + } + + + private static final Long COMPANY_USER_ID = 900L; + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @MockBean private OnboardingChecklistService onboardingChecklistService; + @MockBean private RedisTemplate redisTemplate; + + @Test + void listCompanyItems_shouldReturnCurrentCompanyItems() throws Exception { + when(onboardingChecklistService.listCompanyChecklistItems(COMPANY_USER_ID)) + .thenReturn(List.of(view(11L, true, 0L))); + + mockMvc.perform(get("/onboarding/company/checklist-items") + .header("Authorization", companyToken()) + .header("X-Request-Id", "req-t4-list")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.requestId").value("req-t4-list")) + .andExpect(jsonPath("$.data[0].itemId").value(11)) + .andExpect(jsonPath("$.data[0].source").value("COMPANY")) + .andExpect(jsonPath("$.data[0].active").value(true)) + .andExpect(jsonPath("$.data[0].version").value(0)); + + verify(onboardingChecklistService).listCompanyChecklistItems(COMPANY_USER_ID); + } + + @Test + void createCompanyItem_shouldReturnCreatedContract() throws Exception { + when(onboardingChecklistService.createCompanyChecklistItem( + eq(COMPANY_USER_ID), any(CompanyChecklistItemCreateRequest.class))) + .thenReturn(view(12L, true, 0L)); + + mockMvc.perform(post("/onboarding/company/checklist-items") + .header("Authorization", companyToken()) + .header("X-Request-Id", "req-t4-create") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "Submit badge", + "description": "Bring a photo", + "category": "ADMIN", + "sortOrder": 200, + "required": false, + "idempotencyKey": "key-1" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.itemId").value(12)) + .andExpect(jsonPath("$.data.source").value("COMPANY")) + .andExpect(jsonPath("$.data.version").value(0)); + + verify(onboardingChecklistService).createCompanyChecklistItem( + eq(COMPANY_USER_ID), + argThat(request -> "Submit badge".equals(request.getTitle()) + && Integer.valueOf(200).equals(request.getSortOrder()) + && "key-1".equals(request.getIdempotencyKey()))); + } + + @Test + void createCompanyItem_withInvalidFields_shouldReturn400BeforeService() throws Exception { + mockMvc.perform(post("/onboarding/company/checklist-items") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": " ", + "description": "ok", + "category": "INVALID", + "sortOrder": 10000, + "idempotencyKey": "key-1" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + verify(onboardingChecklistService, never()) + .createCompanyChecklistItem(any(), any()); + } + + @Test + void createCompanyItem_withIdempotencyConflict_shouldReturn409() throws Exception { + when(onboardingChecklistService.createCompanyChecklistItem( + eq(COMPANY_USER_ID), any(CompanyChecklistItemCreateRequest.class))) + .thenThrow(new BusinessException(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT)); + + mockMvc.perform(post("/onboarding/company/checklist-items") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Submit badge\",\"category\":\"ADMIN\",\"idempotencyKey\":\"same\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code") + .value(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT.getCode())); + } + + @Test + void updateCompanyItem_shouldPassVersionAndReturnUpdatedView() throws Exception { + when(onboardingChecklistService.updateCompanyChecklistItem( + eq(COMPANY_USER_ID), eq(13L), any(CompanyChecklistItemUpdateRequest.class))) + .thenReturn(view(13L, false, 5L)); + + mockMvc.perform(put("/onboarding/company/checklist-items/13") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "Updated badge", + "description": "New details", + "category": "TRAINING", + "sortOrder": 300, + "required": true, + "active": false, + "version": 4 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.itemId").value(13)) + .andExpect(jsonPath("$.data.active").value(false)) + .andExpect(jsonPath("$.data.version").value(5)); + + verify(onboardingChecklistService).updateCompanyChecklistItem( + eq(COMPANY_USER_ID), + eq(13L), + argThat(request -> "Updated badge".equals(request.getTitle()) + && Long.valueOf(4L).equals(request.getVersion()))); + } + + @Test + void updateCompanyItem_withStaleVersion_shouldReturn409() throws Exception { + when(onboardingChecklistService.updateCompanyChecklistItem( + eq(COMPANY_USER_ID), eq(13L), any(CompanyChecklistItemUpdateRequest.class))) + .thenThrow(new BusinessException( + ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE)); + + mockMvc.perform(put("/onboarding/company/checklist-items/13") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "Updated badge", + "category": "ADMIN", + "sortOrder": 300, + "required": true, + "active": true, + "version": 2 + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code") + .value(ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE.getCode())); + } + + @Test + void deleteCompanyItem_shouldReturnSoftDeletedView() throws Exception { + when(onboardingChecklistService.deleteCompanyChecklistItem( + COMPANY_USER_ID, 14L, 3L)) + .thenReturn(view(14L, false, 4L)); + + mockMvc.perform(delete("/onboarding/company/checklist-items/14") + .param("version", "3") + .header("Authorization", companyToken()) + .header("X-Request-Id", "req-t4-delete")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.requestId").value("req-t4-delete")) + .andExpect(jsonPath("$.data.itemId").value(14)) + .andExpect(jsonPath("$.data.active").value(false)) + .andExpect(jsonPath("$.data.version").value(4)); + + verify(onboardingChecklistService) + .deleteCompanyChecklistItem(COMPANY_USER_ID, 14L, 3L); + } + + @Test + void updateCompanyItem_forOtherCompanyItem_shouldReturn404() throws Exception { + when(onboardingChecklistService.updateCompanyChecklistItem( + eq(COMPANY_USER_ID), eq(99L), any(CompanyChecklistItemUpdateRequest.class))) + .thenThrow(new BusinessException(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND)); + + mockMvc.perform(put("/onboarding/company/checklist-items/99") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "Updated badge", + "category": "ADMIN", + "sortOrder": 300, + "required": true, + "active": true, + "version": 2 + } + """)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code") + .value(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode())); + } + + @Test + void companyChecklist_withoutToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/onboarding/company/checklist-items")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void companyChecklist_withNonCompanyRole_shouldReturn403() throws Exception { + when(onboardingChecklistService.listCompanyChecklistItems(COMPANY_USER_ID)) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + + mockMvc.perform(get("/onboarding/company/checklist-items") + .header("Authorization", studentToken())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); + } + + private String companyToken() { + return "Bearer " + com.mydreamjob.integration.LxqTestSessionIssuer.issue(COMPANY_USER_ID, "COMPANY", integrationSessions, integrationUsers, integrationJdbc); + } + + private String studentToken() { + return "Bearer " + com.mydreamjob.integration.LxqTestSessionIssuer.issue(COMPANY_USER_ID, "STUDENT", integrationSessions, integrationUsers, integrationJdbc); + } + + private CompanyChecklistItemView view(Long itemId, boolean active, Long version) { + return CompanyChecklistItemView.builder() + .itemId(itemId) + .source("COMPANY") + .itemKey("company-7-item-" + itemId) + .title("Submit badge") + .description("Bring a photo") + .category("ADMIN") + .required(false) + .sortOrder(200) + .active(active) + .version(version) + .build(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java new file mode 100644 index 0000000..12b3a4f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java @@ -0,0 +1,375 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemCreateRequest; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemView; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OnboardingCompanyChecklistServiceTest { + + private static final Long COMPANY_USER_ID = 900L; + private static final Long COMPANY_ID = 7L; + private static final Long OTHER_COMPANY_ID = 8L; + + @Mock private UserRepository userRepository; + @Mock private CompanyRepository companyRepository; + @Mock private OnboardingChecklistItemRepository checklistItemRepository; + @Mock private StudentOnboardingProgressRepository progressRepository; + @InjectMocks private OnboardingChecklistService service; + + @Test + void listCompanyChecklistItems_shouldReturnOnlyOwnCompanyItemsIncludingInactive() { + stubCompanyOwner(); + when(checklistItemRepository.findAll()).thenReturn(List.of( + item(1L, OTHER_COMPANY_ID, "other", "OTHER", 10, true, 0L, null), + item(2L, COMPANY_ID, "second", "ADMIN", 20, false, 4L, null), + item(3L, COMPANY_ID, "first", "DOCUMENT", 10, true, 2L, null), + OnboardingChecklistItem.builder() + .id(4L).scopeType("STANDARD").scopeId(0L).companyId(null) + .itemKey("standard").title("standard").category("OTHER") + .sortOrder(0).active(true).required(false).build())); + + List result = service.listCompanyChecklistItems(COMPANY_USER_ID); + + assertThat(result).extracting(CompanyChecklistItemView::getItemId) + .containsExactly(3L, 2L); + assertThat(result).allSatisfy(view -> { + assertThat(view.getSource()).isEqualTo("COMPANY"); + assertThat(view.getItemKey()).isNotBlank(); + }); + assertThat(result.get(1).getActive()).isFalse(); + assertThat(result.get(1).getVersion()).isEqualTo(4L); + } + + @Test + void createCompanyChecklistItem_shouldValidateAllInputConstraints() { + stubCompanyOwner(); + List invalid = new ArrayList<>(); + invalid.add(createRequest(" ", null, "ADMIN", 10, false, "key")); + invalid.add(createRequest("title", "x".repeat(1001), "ADMIN", 10, false, "key")); + invalid.add(createRequest("title", null, "INVALID", 10, false, "key")); + invalid.add(createRequest("title", null, "ADMIN", -1, false, "key")); + invalid.add(createRequest("title", null, "ADMIN", 10000, false, "key")); + invalid.add(createRequest("title", null, "ADMIN", 10, false, "x".repeat(65))); + + for (CompanyChecklistItemCreateRequest request : invalid) { + assertThatThrownBy(() -> service.createCompanyChecklistItem(COMPANY_USER_ID, request)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.BAD_REQUEST.getCode()); + } + verifyNoInteractions(checklistItemRepository); + } + + @Test + void createCompanyChecklistItem_shouldPersistNormalizedItemAndStableHash() { + stubCompanyOwner(); + when(checklistItemRepository.findCompanyItemsForUpdate(COMPANY_ID)).thenReturn(List.of()); + ArgumentCaptor captor = + ArgumentCaptor.forClass(OnboardingChecklistItem.class); + when(checklistItemRepository.saveAndFlush(any(OnboardingChecklistItem.class))) + .thenAnswer(invocation -> { + OnboardingChecklistItem saved = invocation.getArgument(0); + saved.setId(11L); + saved.setVersion(0L); + return saved; + }); + + CompanyChecklistItemView result = service.createCompanyChecklistItem( + COMPANY_USER_ID, + createRequest(" Submit badge ", " Bring photo ", "ADMIN", null, null, " key-1 ")); + + verify(checklistItemRepository).saveAndFlush(captor.capture()); + OnboardingChecklistItem saved = captor.getValue(); + assertThat(saved.getScopeType()).isEqualTo("COMPANY"); + assertThat(saved.getScopeId()).isEqualTo(COMPANY_ID); + assertThat(saved.getCompanyId()).isEqualTo(COMPANY_ID); + assertThat(saved.getItemKey()).startsWith("company-7-"); + assertThat(saved.getTitle()).isEqualTo("Submit badge"); + assertThat(saved.getDescription()).isEqualTo("Bring photo"); + assertThat(saved.getSortOrder()).isEqualTo(1000); + assertThat(saved.getRequired()).isFalse(); + assertThat(saved.getIdempotencyKey()).isEqualTo("key-1"); + assertThat(saved.getRequestHash()).hasSize(64).doesNotContain("Submit badge"); + assertThat(result.getItemId()).isEqualTo(11L); + assertThat(result.getVersion()).isZero(); + } + + @Test + void createCompanyChecklistItem_idempotentReplay_shouldReturnOriginalWithoutSecondSave() { + stubCompanyOwner(); + AtomicReference stored = new AtomicReference<>(); + when(checklistItemRepository.findCompanyItemsForUpdate(COMPANY_ID)).thenAnswer(invocation -> stored.get() == null + ? List.of() : List.of(stored.get())); + when(checklistItemRepository.saveAndFlush(any(OnboardingChecklistItem.class))) + .thenAnswer(invocation -> { + OnboardingChecklistItem saved = invocation.getArgument(0); + saved.setId(21L); + stored.set(saved); + return saved; + }); + CompanyChecklistItemCreateRequest request = + createRequest("Submit badge", "Details", "ADMIN", 200, false, "same-key"); + + CompanyChecklistItemView first = service.createCompanyChecklistItem(COMPANY_USER_ID, request); + CompanyChecklistItemView replay = service.createCompanyChecklistItem( + COMPANY_USER_ID, + createRequest(" Submit badge ", " Details ", "ADMIN", 200, false, "same-key")); + + assertThat(first.getItemId()).isEqualTo(21L); + assertThat(replay.getItemId()).isEqualTo(21L); + assertThat(replay.getTitle()).isEqualTo("Submit badge"); + verify(checklistItemRepository).saveAndFlush(any(OnboardingChecklistItem.class)); + } + + @Test + void createCompanyChecklistItem_sameKeyDifferentBody_shouldReturnIdempotencyConflict() { + stubCompanyOwner(); + OnboardingChecklistItem existing = item( + 31L, COMPANY_ID, "existing", "ADMIN", 200, true, 0L, "same-key"); + existing.setRequestHash("a".repeat(64)); + when(checklistItemRepository.findCompanyItemsForUpdate(COMPANY_ID)).thenReturn(List.of(existing)); + + assertThatThrownBy(() -> service.createCompanyChecklistItem( + COMPANY_USER_ID, + createRequest("Different", "Details", "ADMIN", 200, false, "same-key"))) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT.getCode()); + verify(checklistItemRepository, never()).saveAndFlush(any()); + } + + @Test + void createCompanyChecklistItem_atFiftyActiveItems_shouldReturnLimitExceeded() { + stubCompanyOwner(); + List existing = new ArrayList<>(); + for (int index = 0; index < 50; index++) { + existing.add(item((long) index + 1, COMPANY_ID, "item-" + index, + "OTHER", index, true, 0L, null)); + } + when(checklistItemRepository.findCompanyItemsForUpdate(COMPANY_ID)).thenReturn(existing); + + assertThatThrownBy(() -> service.createCompanyChecklistItem( + COMPANY_USER_ID, + createRequest("One more", null, "OTHER", 999, false, null))) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode()); + verify(checklistItemRepository, never()).saveAndFlush(any()); + } + + @Test + void updateCompanyChecklistItem_withCurrentVersion_shouldApplyAllMutableFields() { + stubCompanyOwner(); + OnboardingChecklistItem existing = item( + 41L, COMPANY_ID, "update-me", "DOCUMENT", 100, true, 3L, null); + when(checklistItemRepository.findById(41L)).thenReturn(Optional.of(existing)); + when(checklistItemRepository.saveAndFlush(existing)).thenAnswer(invocation -> { + existing.setVersion(4L); + return existing; + }); + + CompanyChecklistItemView result = service.updateCompanyChecklistItem( + COMPANY_USER_ID, + 41L, + updateRequest("Updated", "New details", "TRAINING", 300, true, false, 3L)); + + assertThat(existing.getTitle()).isEqualTo("Updated"); + assertThat(existing.getDescription()).isEqualTo("New details"); + assertThat(existing.getCategory()).isEqualTo("TRAINING"); + assertThat(existing.getSortOrder()).isEqualTo(300); + assertThat(existing.getRequired()).isTrue(); + assertThat(existing.getActive()).isFalse(); + assertThat(result.getVersion()).isEqualTo(4L); + } + + @Test + void updateCompanyChecklistItem_withStaleVersion_shouldReturnConcurrentUpdate() { + stubCompanyOwner(); + OnboardingChecklistItem existing = item( + 42L, COMPANY_ID, "update-me", "DOCUMENT", 100, true, 3L, null); + when(checklistItemRepository.findById(42L)).thenReturn(Optional.of(existing)); + + assertThatThrownBy(() -> service.updateCompanyChecklistItem( + COMPANY_USER_ID, + 42L, + updateRequest("Updated", null, "DOCUMENT", 100, true, true, 2L))) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE.getCode()); + assertThat(existing.getTitle()).isEqualTo("update-me-title"); + verify(checklistItemRepository, never()).saveAndFlush(any()); + } + + @Test + void deleteCompanyChecklistItem_shouldSoftDeleteAndRepeatIdempotently() { + stubCompanyOwner(); + OnboardingChecklistItem existing = item( + 51L, COMPANY_ID, "delete-me", "OTHER", 100, true, 2L, null); + when(checklistItemRepository.findById(51L)).thenReturn(Optional.of(existing)); + when(checklistItemRepository.saveAndFlush(existing)).thenAnswer(invocation -> { + existing.setVersion(3L); + return existing; + }); + + CompanyChecklistItemView first = + service.deleteCompanyChecklistItem(COMPANY_USER_ID, 51L, 2L); + CompanyChecklistItemView repeat = + service.deleteCompanyChecklistItem(COMPANY_USER_ID, 51L, 2L); + + assertThat(first.getActive()).isFalse(); + assertThat(first.getVersion()).isEqualTo(3L); + assertThat(repeat.getActive()).isFalse(); + assertThat(repeat.getVersion()).isEqualTo(3L); + verify(checklistItemRepository).saveAndFlush(existing); + } + + @Test + void companyItemMutation_forOtherCompanyItem_shouldReturnNotFoundWithoutMutation() { + stubCompanyOwner(); + OnboardingChecklistItem other = item( + 61L, OTHER_COMPANY_ID, "other", "OTHER", 100, true, 1L, null); + when(checklistItemRepository.findById(61L)).thenReturn(Optional.of(other)); + + assertThatThrownBy(() -> service.deleteCompanyChecklistItem(COMPANY_USER_ID, 61L, 1L)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode()); + assertThat(other.getActive()).isTrue(); + verify(checklistItemRepository, never()).saveAndFlush(any()); + } + + @Test + void companyOperation_withNonCompanyRole_shouldReturnForbidden() { + when(userRepository.findById(COMPANY_USER_ID)) + .thenReturn(Optional.of(User.builder() + .id(COMPANY_USER_ID) + .roles(Set.of(User.Role.STUDENT)) + .build())); + + assertThatThrownBy(() -> service.listCompanyChecklistItems(COMPANY_USER_ID)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.FORBIDDEN.getCode()); + verifyNoInteractions(companyRepository, checklistItemRepository); + } + + @Test + void companyOperation_withoutCompanyProfile_shouldReturnCompanyNotFound() { + when(userRepository.findById(COMPANY_USER_ID)) + .thenReturn(Optional.of(User.builder() + .id(COMPANY_USER_ID) + .roles(Set.of(User.Role.COMPANY)) + .build())); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.listCompanyChecklistItems(COMPANY_USER_ID)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.COMPANY_NOT_FOUND.getCode()); + verifyNoInteractions(checklistItemRepository); + } + + private void stubCompanyOwner() { + when(userRepository.findById(COMPANY_USER_ID)) + .thenReturn(Optional.of(User.builder() + .id(COMPANY_USER_ID) + .roles(Set.of(User.Role.COMPANY)) + .build())); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) + .thenReturn(Optional.of(Company.builder() + .id(COMPANY_ID) + .ownerUserId(COMPANY_USER_ID) + .name("Example") + .verified(true) + .build())); + } + + private OnboardingChecklistItem item( + Long id, + Long companyId, + String itemKey, + String category, + int sortOrder, + boolean active, + Long version, + String idempotencyKey) { + return OnboardingChecklistItem.builder() + .id(id) + .scopeType("COMPANY") + .scopeId(companyId) + .companyId(companyId) + .itemKey(itemKey) + .title(itemKey + "-title") + .description(itemKey + "-description") + .category(category) + .sortOrder(sortOrder) + .required(false) + .active(active) + .version(version) + .idempotencyKey(idempotencyKey) + .build(); + } + + private CompanyChecklistItemCreateRequest createRequest( + String title, + String description, + String category, + Integer sortOrder, + Boolean required, + String idempotencyKey) { + CompanyChecklistItemCreateRequest request = new CompanyChecklistItemCreateRequest(); + request.setTitle(title); + request.setDescription(description); + request.setCategory(category); + request.setSortOrder(sortOrder); + request.setRequired(required); + request.setIdempotencyKey(idempotencyKey); + return request; + } + + private CompanyChecklistItemUpdateRequest updateRequest( + String title, + String description, + String category, + Integer sortOrder, + Boolean required, + Boolean active, + Long version) { + CompanyChecklistItemUpdateRequest request = new CompanyChecklistItemUpdateRequest(); + request.setTitle(title); + request.setDescription(description); + request.setCategory(category); + request.setSortOrder(sortOrder); + request.setRequired(required); + request.setActive(active); + request.setVersion(version); + return request; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java new file mode 100644 index 0000000..1c970a9 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java @@ -0,0 +1,433 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemCreateRequest; +import com.mydreamjob.onboarding.dto.CompanyChecklistItemView; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeView; +import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; +import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.context.TestPropertySource; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:onboarding_concurrency;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class OnboardingConcurrencyIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(972_000_000L); + private static final AtomicLong ITEM_SEQ = new AtomicLong(1L); + + @Autowired private OnboardingChecklistService checklistService; + @Autowired private CompanyKnowledgeService knowledgeService; + @Autowired private UserRepository userRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private OnboardingChecklistItemRepository checklistItemRepository; + @Autowired private StudentOnboardingProgressRepository progressRepository; + @Autowired private CompanyKnowledgeRepository knowledgeRepository; + + @MockBean private RedisTemplate redisTemplate; + + @BeforeEach + void clearData() { + progressRepository.deleteAll(); + knowledgeRepository.deleteAll(); + checklistItemRepository.deleteAll(); + companyRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + void identicalIdempotencyKeyConcurrentCreatesReplaySameRow() throws Exception { + CompanyOwner owner = createCompanyOwner("幂等企业"); + CompanyChecklistItemCreateRequest request = createRequest("重复请求", "same-key"); + + List> attempts = runConcurrently( + () -> checklistService.createCompanyChecklistItem(owner.userId(), request), + () -> checklistService.createCompanyChecklistItem(owner.userId(), request)); + + assertThat(attempts).allSatisfy(attempt -> assertThat(attempt.error()).isNull()); + assertThat(attempts).extracting(attempt -> attempt.value().getItemId()) + .containsOnly(attempts.get(0).value().getItemId()); + assertThat(companyRows(owner.companyId()).stream() + .filter(item -> "same-key".equals(item.getIdempotencyKey()))) + .hasSize(1); + } + + @Test + void idempotencyKeyRaceWithDifferentBodiesReturns9704() throws Exception { + CompanyOwner owner = createCompanyOwner("幂等冲突企业"); + + List> attempts = runConcurrently( + () -> checklistService.createCompanyChecklistItem( + owner.userId(), createRequest("请求A", "conflict-key")), + () -> checklistService.createCompanyChecklistItem( + owner.userId(), createRequest("请求B", "conflict-key"))); + + assertThat(attempts).filteredOn(attempt -> attempt.error() == null).hasSize(1); + assertThat(attempts).filteredOn(Attempt::failedWithIdempotencyConflict).hasSize(1); + assertThat(companyRows(owner.companyId()).stream() + .filter(item -> "conflict-key".equals(item.getIdempotencyKey()))) + .hasSize(1); + } + + + @Test + void inactiveItemsDoNotConsumeLimitAndExistingKeyStillReplays() throws Exception { + CompanyOwner owner = createCompanyOwner("停用项企业"); + seedCompanyItems(owner.companyId(), 49); + CompanyChecklistItemCreateRequest fiftieth = createRequest("第50条", "replay-key"); + CompanyChecklistItemView created = checklistService.createCompanyChecklistItem( + owner.userId(), fiftieth); + seedInactiveCompanyItems(owner.companyId(), 3); + + CompanyChecklistItemCreateRequest newRequest = createRequest("第51条", "new-over-limit"); + assertThat(failedWith(() -> checklistService.createCompanyChecklistItem( + owner.userId(), newRequest))).isEqualTo( + ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode()); + + CompanyChecklistItemView replay = checklistService.createCompanyChecklistItem( + owner.userId(), fiftieth); + assertThat(replay.getItemId()).isEqualTo(created.getItemId()); + assertThat(companyRows(owner.companyId()).stream() + .filter(item -> Boolean.TRUE.equals(item.getActive()))) + .hasSize(50); + } + + @Test + void sameIdempotencyKeyIsIsolatedByCompany() throws Exception { + CompanyOwner first = createCompanyOwner("企业甲"); + CompanyOwner second = createCompanyOwner("企业乙"); + CompanyChecklistItemCreateRequest request = createRequest("独立企业请求", "shared-key"); + + List> attempts = runConcurrently( + () -> checklistService.createCompanyChecklistItem(first.userId(), request), + () -> checklistService.createCompanyChecklistItem(second.userId(), request)); + + assertThat(attempts).allSatisfy(attempt -> assertThat(attempt.error()).isNull()); + assertThat(companyRows(first.companyId())).hasSize(1); + assertThat(companyRows(second.companyId())).hasSize(1); + } + + @Test + void firstProgressCreateWithNonZeroVersionReturns9705() { + CompanyOwner owner = createCompanyOwner("进度版本企业"); + Long student = createUser(User.Role.STUDENT); + OnboardingChecklistItem item = createStandardItem("进度版本项"); + + ProgressUpdateRequest request = progressRequest(true, 1L); + + assertThat(failedWith(() -> checklistService.updateCompletion( + student, owner.companyId(), item.getId(), request))) + .isEqualTo(ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode()); + assertThat(progressRepository.findByStudentUserIdAndItemId(student, item.getId())) + .isEmpty(); + } + + @Test + void concurrentFirstProgressCreatesProduceSingleRow() throws Exception { + CompanyOwner owner = createCompanyOwner("首次进度企业"); + Long student = createUser(User.Role.STUDENT); + OnboardingChecklistItem item = createStandardItem("首次进度项"); + + List> calls = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + calls.add(() -> checklistService.updateCompletion( + student, owner.companyId(), item.getId(), progressRequest(true, 0L))); + } + + List> attempts = runConcurrently(calls); + + assertThat(attempts).allSatisfy(attempt -> assertThat(attempt.error()).isNull()); + assertThat(progressRepository.findByStudentUserIdAndItemId(student, item.getId())) + .isPresent(); + assertThat(progressRepository.findAll().stream() + .filter(row -> row.getStudentUserId().equals(student) + && row.getItemId().equals(item.getId()))) + .hasSize(1); + } + + @Test + void simultaneousFirstKnowledgeUpsertsAreDeterministic() throws Exception { + CompanyOwner owner = createCompanyOwner("知识并发企业"); + CompanyKnowledgeUpdateRequest request = knowledgeRequest("共同文化", null, null, null); + + List> attempts = runConcurrently( + () -> knowledgeService.upsertCompanyKnowledge(owner.userId(), request), + () -> knowledgeService.upsertCompanyKnowledge(owner.userId(), request)); + + assertThat(attempts).allSatisfy(attempt -> assertThat(attempt.error()).isNull()); + assertThat(knowledgeRepository.findByCompanyId(owner.companyId())).isPresent(); + assertThat(knowledgeRepository.findAll().stream() + .filter(row -> row.getCompanyId().equals(owner.companyId()))) + .hasSize(1); + } + + @Test + void identicalKnowledgeCreateReplayWithoutVersionIsIdempotent() { + CompanyOwner owner = createCompanyOwner("知识重放企业"); + CompanyKnowledgeUpdateRequest request = knowledgeRequest("重放文化", "制度", null, null); + + knowledgeService.upsertCompanyKnowledge(owner.userId(), request); + CompanyKnowledgeView replay = knowledgeService.upsertCompanyKnowledge(owner.userId(), request); + + assertThat(replay.getConfigured()).isTrue(); + assertThat(replay.getVersion()).isZero(); + assertThat(knowledgeRepository.findAll().stream() + .filter(row -> row.getCompanyId().equals(owner.companyId()))) + .hasSize(1); + } + + @Test + void concurrentKnowledgeUpdatesWithSameVersionReturn9706() throws Exception { + CompanyOwner owner = createCompanyOwner("知识版本企业"); + knowledgeRepository.save(CompanyKnowledge.builder() + .companyId(owner.companyId()) + .culture("初始文化") + .build()); + + List> attempts = runConcurrently( + () -> knowledgeService.upsertCompanyKnowledge( + owner.userId(), knowledgeRequest("更新A", null, null, 0L)), + () -> knowledgeService.upsertCompanyKnowledge( + owner.userId(), knowledgeRequest("更新B", null, null, 0L))); + + assertThat(attempts).filteredOn(attempt -> attempt.error() == null).hasSize(1); + assertThat(attempts).filteredOn(Attempt::failedWithKnowledgeConflict).hasSize(1); + assertThat(knowledgeRepository.findByCompanyId(owner.companyId()).orElseThrow().getVersion()) + .isEqualTo(1L); + } + + + @Nested + @TestPropertySource(properties = + "spring.datasource.hikari.transaction-isolation=TRANSACTION_REPEATABLE_READ") + class RepeatableReadLimitBoundary { + + @Test + void concurrentCreatesAtLimitBoundaryNeverWriteFiftyFirstItem() throws Exception { + CompanyOwner owner = createCompanyOwner("上限企业"); + seedCompanyItems(owner.companyId(), 49); + + List> attempts = runConcurrently( + () -> checklistService.createCompanyChecklistItem( + owner.userId(), createRequest("边界A", "limit-a")), + () -> checklistService.createCompanyChecklistItem( + owner.userId(), createRequest("边界B", "limit-b"))); + + assertThat(attempts).filteredOn(attempt -> attempt.error() == null).hasSize(1); + assertThat(attempts).filteredOn(Attempt::failedWithItemLimit).hasSize(1); + assertThat(companyRows(owner.companyId())) + .filteredOn(item -> Boolean.TRUE.equals(item.getActive())) + .hasSize(50); + } + } + + private int failedWith(ThrowingSupplier supplier) { + try { + supplier.get(); + return -1; + } catch (BusinessException ex) { + return ex.getErrorCodeInt(); + } + } + + private CompanyChecklistItemCreateRequest createRequest(String title, String idempotencyKey) { + CompanyChecklistItemCreateRequest request = new CompanyChecklistItemCreateRequest(); + request.setTitle(title); + request.setDescription(title + "说明"); + request.setCategory("ADMIN"); + request.setSortOrder(200); + request.setRequired(false); + request.setIdempotencyKey(idempotencyKey); + return request; + } + + private ProgressUpdateRequest progressRequest(boolean completed, Long version) { + ProgressUpdateRequest request = new ProgressUpdateRequest(); + request.setCompleted(completed); + request.setVersion(version); + return request; + } + + private CompanyKnowledgeUpdateRequest knowledgeRequest( + String culture, String workPolicies, String onboardingGuide, Long version) { + CompanyKnowledgeUpdateRequest request = new CompanyKnowledgeUpdateRequest(); + request.setCulture(culture); + request.setWorkPolicies(workPolicies); + request.setOnboardingGuide(onboardingGuide); + request.setVersion(version); + return request; + } + + private List seedCompanyItems(Long companyId, int count) { + List items = new ArrayList<>(); + for (int i = 0; i < count; i++) { + items.add(OnboardingChecklistItem.builder() + .scopeType("COMPANY") + .scopeId(companyId) + .companyId(companyId) + .itemKey("seed-" + companyId + "-" + ITEM_SEQ.getAndIncrement()) + .title("种子项-" + i) + .description("种子说明") + .category("OTHER") + .sortOrder(1000 + i) + .required(false) + .active(true) + .idempotencyKey("seed-key-" + companyId + "-" + i) + .requestHash("a".repeat(64)) + .build()); + } + return checklistItemRepository.saveAllAndFlush(items); + } + + private void seedInactiveCompanyItems(Long companyId, int count) { + List items = new ArrayList<>(); + for (int i = 0; i < count; i++) { + items.add(OnboardingChecklistItem.builder() + .scopeType("COMPANY") + .scopeId(companyId) + .companyId(companyId) + .itemKey("inactive-" + companyId + "-" + ITEM_SEQ.getAndIncrement()) + .title("停用项-" + i) + .category("OTHER") + .sortOrder(2000 + i) + .required(false) + .active(false) + .build()); + } + checklistItemRepository.saveAllAndFlush(items); + } + + private OnboardingChecklistItem createStandardItem(String title) { + return checklistItemRepository.saveAndFlush(OnboardingChecklistItem.builder() + .scopeType("STANDARD") + .scopeId(0L) + .companyId(null) + .itemKey("standard-" + ITEM_SEQ.getAndIncrement()) + .title(title) + .description("通用说明") + .category("DOCUMENT") + .sortOrder(10) + .required(true) + .active(true) + .build()); + } + + private List companyRows(Long companyId) { + return checklistItemRepository.findAll().stream() + .filter(item -> companyId.equals(item.getCompanyId())) + .toList(); + } + + private CompanyOwner createCompanyOwner(String name) { + Long userId = createUser(User.Role.COMPANY); + Long companyId = companyRepository.save(Company.builder() + .ownerUserId(userId) + .name(name) + .verified(true) + .build()).getId(); + return new CompanyOwner(userId, companyId); + } + + private Long createUser(User.Role role) { + return userRepository.save(User.builder() + .phone(String.valueOf(PHONE_SEQ.getAndIncrement())) + .nickname(role.name()) + .role(role) + .roles(Set.of(role)) + .active(true) + .verified(true) + .build()).getId(); + } + + @SafeVarargs + private final List> runConcurrently(Callable... actions) throws Exception { + return runConcurrently(List.of(actions)); + } + + private List> runConcurrently(List> actions) throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(actions.size()); + CountDownLatch ready = new CountDownLatch(actions.size()); + CountDownLatch start = new CountDownLatch(1); + List>> futures = new ArrayList<>(); + try { + for (Callable action : actions) { + futures.add(pool.submit(() -> { + ready.countDown(); + if (!start.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("concurrent test start timed out"); + } + try { + return new Attempt<>(action.call(), null); + } catch (Throwable ex) { + return new Attempt<>(null, ex); + } + })); + } + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + List> results = new ArrayList<>(); + for (Future> future : futures) { + results.add(future.get(20, TimeUnit.SECONDS)); + } + return results; + } finally { + pool.shutdownNow(); + } + } + + private record CompanyOwner(Long userId, Long companyId) { + } + + private record Attempt(T value, Throwable error) { + boolean failedWithIdempotencyConflict() { + return error instanceof BusinessException ex + && ex.getErrorCodeInt() == ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT.getCode(); + } + + boolean failedWithItemLimit() { + return error instanceof BusinessException ex + && ex.getErrorCodeInt() == ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode(); + } + + boolean failedWithKnowledgeConflict() { + return error instanceof BusinessException ex + && ex.getErrorCodeInt() + == ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode(); + } + } + + @FunctionalInterface + private interface ThrowingSupplier { + T get(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingEndToEndIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingEndToEndIntegrationTest.java new file mode 100644 index 0000000..be9594d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingEndToEndIntegrationTest.java @@ -0,0 +1,761 @@ +package com.mydreamjob.onboarding; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.review.JobReview; +import com.mydreamjob.review.JobReviewDimensionScore; +import com.mydreamjob.review.JobReviewDimensionScoreRepository; +import com.mydreamjob.review.JobReviewRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:onboarding_e2e;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class OnboardingEndToEndIntegrationTest { + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.AuthSessionService integrationSessions; + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.UserRepository integrationUsers; + @org.springframework.beans.factory.annotation.Autowired private org.springframework.jdbc.core.JdbcTemplate integrationJdbc; + @org.junit.jupiter.api.AfterEach void cleanupIntegrationSessions() { + com.mydreamjob.integration.LxqTestSessionIssuer.cleanup(integrationJdbc); + } + + + private static final AtomicLong PHONE_SEQ = new AtomicLong(981_000_000L); + private static final AtomicLong ITEM_SEQ = new AtomicLong(1L); + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private OnboardingChecklistItemRepository checklistItemRepository; + @Autowired private StudentOnboardingProgressRepository progressRepository; + @Autowired private CompanyKnowledgeRepository knowledgeRepository; + @Autowired private JobRepository jobRepository; + @Autowired private JobReviewRepository reviewRepository; + @Autowired private JobReviewDimensionScoreRepository dimensionScoreRepository; + + @MockBean private RedisTemplate redisTemplate; + + @BeforeEach + void clearData() { + dimensionScoreRepository.deleteAll(); + reviewRepository.deleteAll(); + jobRepository.deleteAll(); + progressRepository.deleteAll(); + knowledgeRepository.deleteAll(); + checklistItemRepository.deleteAll(); + companyRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + void allNineOnboardingEndpointsWorkThroughHttpAndPersistence() throws Exception { + Long companyUser = createUser(User.Role.COMPANY); + Long companyId = createCompany(companyUser, "端到端企业"); + Long student = createUser(User.Role.STUDENT); + OnboardingChecklistItem standardItem = saveStandardItem("e2e-standard", "准备证件", 100); + + JsonNode companyItemsBefore = performJson(get("/onboarding/company/checklist-items") + .header(HttpHeaders.AUTHORIZATION, bearer(companyUser)), 200); + assertThat(companyItemsBefore.path("data").isArray()).isTrue(); + assertThat(companyItemsBefore.path("data")).isEmpty(); + + JsonNode knowledgeBefore = performJson(get("/onboarding/company/knowledge") + .header(HttpHeaders.AUTHORIZATION, bearer(companyUser)), 200); + assertThat(knowledgeBefore.at("/data/configured").asBoolean()).isFalse(); + assertThat(knowledgeBefore.at("/data/version").asLong()).isZero(); + + JsonNode knowledgeCreated = performJson(put("/onboarding/company/knowledge") + .header(HttpHeaders.AUTHORIZATION, bearer(companyUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "culture": "透明沟通", + "workPolicies": "弹性工作制", + "onboardingGuide": "A 座前台报到" + } + """), 200); + assertThat(knowledgeCreated.at("/data/configured").asBoolean()).isTrue(); + assertThat(knowledgeCreated.at("/data/version").asLong()).isZero(); + assertThat(knowledgeCreated.at("/data/culture").asText()).isEqualTo("透明沟通"); + + JsonNode companyItemCreated = performJson(post("/onboarding/company/checklist-items") + .header(HttpHeaders.AUTHORIZATION, bearer(companyUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "提交导师联系方式", + "description": "入职前补充", + "category": "ADMIN", + "sortOrder": 200, + "required": false, + "idempotencyKey": "e2e-create-key" + } + """), 200); + long companyItemId = companyItemCreated.at("/data/itemId").asLong(); + long companyItemVersion = companyItemCreated.at("/data/version").asLong(); + assertThat(companyItemCreated.at("/data/source").asText()).isEqualTo("COMPANY"); + + JsonNode companyItems = performJson(get("/onboarding/company/checklist-items") + .header(HttpHeaders.AUTHORIZATION, bearer(companyUser)), 200); + assertThat(companyItems.path("data")).hasSize(1); + assertThat(companyItems.at("/data/0/itemId").asLong()).isEqualTo(companyItemId); + + JsonNode mergedChecklist = performJson(get( + "/onboarding/companies/{companyId}/checklist", companyId) + .header(HttpHeaders.AUTHORIZATION, bearer(student)), 200); + assertThat(mergedChecklist.at("/data/totalItems").asInt()).isEqualTo(2); + assertThat(mergedChecklist.at("/data/completedItems").asInt()).isZero(); + assertThat(mergedChecklist.at("/data/customized").asBoolean()).isTrue(); + assertThat(mergedChecklist.at("/data/items/0/source").asText()).isEqualTo("STANDARD"); + assertThat(mergedChecklist.at("/data/items/1/source").asText()).isEqualTo("COMPANY"); + + JsonNode progress = performJson(put( + "/onboarding/companies/{companyId}/checklist/items/{itemId}/completion", + companyId, standardItem.getId()) + .header(HttpHeaders.AUTHORIZATION, bearer(student)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"completed": true, "version": 0} + """), 200); + assertThat(progress.at("/data/completed").asBoolean()).isTrue(); + assertThat(progress.at("/data/completedItems").asInt()).isEqualTo(1); + assertThat(progress.at("/data/completionRate").asDouble()).isEqualTo(50.0); + + JsonNode checklistAfterProgress = performJson(get( + "/onboarding/companies/{companyId}/checklist", companyId) + .header(HttpHeaders.AUTHORIZATION, bearer(student)), 200); + assertThat(checklistAfterProgress.at("/data/completedItems").asInt()).isEqualTo(1); + assertThat(checklistAfterProgress.at("/data/items/0/completed").asBoolean()).isTrue(); + + JsonNode studentKnowledge = performJson(get( + "/onboarding/companies/{companyId}/knowledge", companyId) + .header(HttpHeaders.AUTHORIZATION, bearer(student)), 200); + assertThat(studentKnowledge.at("/data/knowledgeState").asText()).isEqualTo("CONFIGURED"); + assertThat(studentKnowledge.at("/data/knowledge/culture").asText()).isEqualTo("透明沟通"); + assertThat(studentKnowledge.at("/data/reviewSummary/state").asText()).isEqualTo("EMPTY"); + + JsonNode companyItemUpdated = performJson(put( + "/onboarding/company/checklist-items/{itemId}", companyItemId) + .header(HttpHeaders.AUTHORIZATION, bearer(companyUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "提交导师联系方式(已更新)", + "description": "入职前由 HR 补充", + "category": "ADMIN", + "sortOrder": 210, + "required": true, + "active": true, + "version": %d + } + """.formatted(companyItemVersion)), 200); + long updatedVersion = companyItemUpdated.at("/data/version").asLong(); + assertThat(companyItemUpdated.at("/data/title").asText()) + .isEqualTo("提交导师联系方式(已更新)"); + + JsonNode deleted = performJson(delete( + "/onboarding/company/checklist-items/{itemId}", companyItemId) + .param("version", String.valueOf(updatedVersion)) + .header(HttpHeaders.AUTHORIZATION, bearer(companyUser)), 200); + long deletedVersion = deleted.at("/data/version").asLong(); + assertThat(deleted.at("/data/active").asBoolean()).isFalse(); + + JsonNode deletedAgain = performJson(delete( + "/onboarding/company/checklist-items/{itemId}", companyItemId) + .param("version", "0") + .header(HttpHeaders.AUTHORIZATION, bearer(companyUser)), 200); + assertThat(deletedAgain.at("/data/active").asBoolean()).isFalse(); + assertThat(deletedAgain.at("/data/version").asLong()).isEqualTo(deletedVersion); + + JsonNode checklistAfterDelete = performJson(get( + "/onboarding/companies/{companyId}/checklist", companyId) + .header(HttpHeaders.AUTHORIZATION, bearer(student)), 200); + assertThat(checklistAfterDelete.at("/data/totalItems").asInt()).isEqualTo(1); + assertThat(checklistAfterDelete.at("/data/completedItems").asInt()).isEqualTo(1); + assertThat(checklistAfterDelete.at("/data/completionRate").asDouble()).isEqualTo(100.0); + assertThat(checklistAfterDelete.at("/data/customized").asBoolean()).isFalse(); + + assertThat(progressRepository.count()).isEqualTo(1); + assertThat(knowledgeRepository.findByCompanyId(companyId)).isPresent(); + } + + @Test + void studentCompletionIsIdempotentCancellableAndVersionProtected() throws Exception { + Long companyUser = createUser(User.Role.COMPANY); + Long companyId = createCompany(companyUser, "进度企业"); + Long student = createUser(User.Role.STUDENT); + OnboardingChecklistItem item = saveStandardItem("progress-standard", "准备材料", 100); + + JsonNode first = performJson(completionRequest(companyId, item.getId(), student, + "{\"completed\": true, \"version\": 0}"), 200); + String completedAt = first.at("/data/completedAt").asText(); + LocalDateTime persistedCompletedAt = progressRepository + .findByStudentUserIdAndItemId(student, item.getId()) + .orElseThrow() + .getCompletedAt(); + assertThat(first.at("/data/completed").asBoolean()).isTrue(); + assertThat(first.at("/data/progressVersion").asLong()).isZero(); + assertThat(completedAt).isNotBlank(); + + JsonNode repeated = performJson(completionRequest(companyId, item.getId(), student, + "{\"completed\": true, \"version\": 999}"), 200); + assertThat(repeated.at("/data/completed").asBoolean()).isTrue(); + assertThat(repeated.at("/data/progressVersion").asLong()).isZero(); + assertThat(LocalDateTime.parse(repeated.at("/data/completedAt").asText())) + .isEqualTo(persistedCompletedAt); + + JsonNode cancelled = performJson(completionRequest(companyId, item.getId(), student, + "{\"completed\": false, \"version\": 0}"), 200); + assertThat(cancelled.at("/data/completed").asBoolean()).isFalse(); + assertThat(cancelled.at("/data/completedAt").isMissingNode() + || cancelled.at("/data/completedAt").isNull()).isTrue(); + assertThat(cancelled.at("/data/progressVersion").asLong()).isEqualTo(1L); + + JsonNode conflict = performJson(completionRequest(companyId, item.getId(), student, + "{\"completed\": true, \"version\": 0}"), 409); + assertThat(conflict.path("code").asInt()) + .isEqualTo(ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode()); + + StudentOnboardingProgress unchanged = progressRepository + .findByStudentUserIdAndItemId(student, item.getId()) + .orElseThrow(); + assertThat(unchanged.getCompleted()).isFalse(); + assertThat(unchanged.getCompletedAt()).isNull(); + assertThat(unchanged.getVersion()).isEqualTo(1L); + + JsonNode repeatedCancellation = performJson(completionRequest( + companyId, item.getId(), student, + "{\"completed\": false, \"version\": 1}"), 200); + assertThat(repeatedCancellation.at("/data/completed").asBoolean()).isFalse(); + assertThat(repeatedCancellation.at("/data/progressVersion").asLong()).isEqualTo(1L); + assertThat(progressRepository.count()).isEqualTo(1); + } + + @Test + void companyChecklistCrudEnforcesIdempotencyLimitAndOwnership() throws Exception { + Long companyAUser = createUser(User.Role.COMPANY); + Long companyA = createCompany(companyAUser, "清单企业A"); + Long companyBUser = createUser(User.Role.COMPANY); + createCompany(companyBUser, "清单企业B"); + + String createBody = """ + { + "title": "稳定幂等项", + "description": "第一次请求", + "category": "OTHER", + "sortOrder": 50, + "required": false, + "idempotencyKey": "stable-key" + } + """; + JsonNode first = performJson(post("/onboarding/company/checklist-items") + .header(HttpHeaders.AUTHORIZATION, bearer(companyAUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(createBody), 200); + long itemId = first.at("/data/itemId").asLong(); + + JsonNode replay = performJson(post("/onboarding/company/checklist-items") + .header(HttpHeaders.AUTHORIZATION, bearer(companyAUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(createBody), 200); + assertThat(replay.at("/data/itemId").asLong()).isEqualTo(itemId); + assertThat(checklistItemRepository.findAll().stream() + .filter(item -> itemId == item.getId()) + .count()).isEqualTo(1); + + JsonNode keyConflict = performJson(post("/onboarding/company/checklist-items") + .header(HttpHeaders.AUTHORIZATION, bearer(companyAUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "不同请求体", + "description": "第一次请求", + "category": "OTHER", + "sortOrder": 50, + "required": false, + "idempotencyKey": "stable-key" + } + """), 409); + assertThat(keyConflict.path("code").asInt()) + .isEqualTo(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT.getCode()); + + seedCompanyItems(companyA, 49, true); + assertThat(activeCompanyItems(companyA)).hasSize(50); + + JsonNode limitExceeded = performJson(post("/onboarding/company/checklist-items") + .header(HttpHeaders.AUTHORIZATION, bearer(companyAUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "第五十一项", + "category": "OTHER", + "sortOrder": 999, + "required": false + } + """), 409); + assertThat(limitExceeded.path("code").asInt()) + .isEqualTo(ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode()); + assertThat(activeCompanyItems(companyA)).hasSize(50); + + JsonNode otherCompanyList = performJson(get("/onboarding/company/checklist-items") + .header(HttpHeaders.AUTHORIZATION, bearer(companyBUser)), 200); + assertThat(otherCompanyList.path("data")).isEmpty(); + + JsonNode crossUpdate = performJson(put( + "/onboarding/company/checklist-items/{itemId}", itemId) + .header(HttpHeaders.AUTHORIZATION, bearer(companyBUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "越权修改", + "category": "OTHER", + "sortOrder": 50, + "required": false, + "active": true, + "version": 0 + } + """), 404); + assertThat(crossUpdate.path("code").asInt()) + .isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode()); + + JsonNode crossDelete = performJson(delete( + "/onboarding/company/checklist-items/{itemId}", itemId) + .param("version", "0") + .header(HttpHeaders.AUTHORIZATION, bearer(companyBUser)), 404); + assertThat(crossDelete.path("code").asInt()) + .isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode()); + + OnboardingChecklistItem unchanged = checklistItemRepository.findById(itemId).orElseThrow(); + assertThat(unchanged.getTitle()).isEqualTo("稳定幂等项"); + assertThat(unchanged.getActive()).isTrue(); + assertThat(activeCompanyItems(companyA)).hasSize(50); + } + + @Test + void companyKnowledgeOverviewMovesFromBasicOnlyEmptyToConfiguredInsufficientAndSufficient() + throws Exception { + Long companyUser = createUser(User.Role.COMPANY); + Long companyId = createCompany(companyUser, "知识企业"); + Long student = createUser(User.Role.STUDENT); + Long jobId = createJob(companyId, "知识岗位"); + + JsonNode basicOnly = studentKnowledge(companyId, student, 200); + assertThat(basicOnly.at("/data/knowledgeState").asText()).isEqualTo("BASIC_ONLY"); + assertThat(basicOnly.at("/data/knowledge").isMissingNode()).isTrue(); + assertThat(basicOnly.at("/data/reviewSummary/state").asText()).isEqualTo("EMPTY"); + + performJson(put("/onboarding/company/knowledge") + .header(HttpHeaders.AUTHORIZATION, bearer(companyUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "culture": "务实", + "workPolicies": "固定工时", + "onboardingGuide": "提前十分钟到岗" + } + """), 200); + + JsonNode configuredEmpty = studentKnowledge(companyId, student, 200); + assertThat(configuredEmpty.at("/data/knowledgeState").asText()).isEqualTo("CONFIGURED"); + assertThat(configuredEmpty.at("/data/knowledge/culture").asText()).isEqualTo("务实"); + assertThat(configuredEmpty.at("/data/reviewSummary/state").asText()).isEqualTo("EMPTY"); + + JobReview visibleThree = saveReview(jobId, createUser(User.Role.STUDENT), 3, + "[\"visible-a\"]", "PUBLISHED", JobReview.MODERATION_APPROVED); + JobReview visibleFour = saveReview(jobId, createUser(User.Role.STUDENT), 4, + "[\"visible-b\"]", "PUBLISHED", JobReview.MODERATION_AUTO_APPROVED); + saveDimension(visibleThree, "TEAM_ATMOSPHERE", 3); + saveDimension(visibleFour, "TEAM_ATMOSPHERE", 4); + + JsonNode insufficient = studentKnowledge(companyId, student, 200); + assertThat(insufficient.at("/data/reviewSummary/state").asText()).isEqualTo("INSUFFICIENT"); + assertThat(insufficient.at("/data/reviewSummary/totalReviews").asInt()).isEqualTo(2); + assertThat(insufficient.at("/data/reviewSummary/averageRating").isMissingNode()).isTrue(); + assertThat(insufficient.at("/data/reviewSummary/ratingDistribution").isMissingNode()).isTrue(); + assertThat(insufficient.at("/data/reviewSummary/topTags").isMissingNode()).isTrue(); + assertThat(insufficient.at("/data/reviewSummary/dimensionSummary").isMissingNode()).isTrue(); + + JobReview hidden = saveReview(jobId, createUser(User.Role.STUDENT), 1, + "[\"hidden-tag\"]", "HIDDEN", JobReview.MODERATION_APPROVED); + saveReview(jobId, createUser(User.Role.STUDENT), 1, + "[\"pending-tag\"]", "PUBLISHED", JobReview.MODERATION_PENDING); + saveDimension(hidden, "TEAM_ATMOSPHERE", 1); + + JsonNode stillInsufficient = studentKnowledge(companyId, student, 200); + assertThat(stillInsufficient.at("/data/reviewSummary/state").asText()) + .isEqualTo("INSUFFICIENT"); + assertThat(stillInsufficient.at("/data/reviewSummary/totalReviews").asInt()).isEqualTo(2); + + JobReview visibleFive = saveReview(jobId, createUser(User.Role.STUDENT), 5, + "[\"visible-c\"]", "PUBLISHED", JobReview.MODERATION_APPROVED); + saveDimension(visibleFive, "TEAM_ATMOSPHERE", 5); + + JsonNode sufficient = studentKnowledge(companyId, student, 200); + assertThat(sufficient.at("/data/reviewSummary/state").asText()).isEqualTo("SUFFICIENT"); + assertThat(sufficient.at("/data/reviewSummary/totalReviews").asInt()).isEqualTo(3); + assertThat(sufficient.at("/data/reviewSummary/averageRating").asDouble()).isEqualTo(4.0); + assertThat(sufficient.at("/data/reviewSummary/ratingDistribution/rating5").asInt()) + .isEqualTo(1); + assertThat(sufficient.at("/data/reviewSummary/topTags")).hasSize(3); + assertThat(sufficient.at("/data/reviewSummary/topTags/0").asText()).isEqualTo("visible-a"); + assertThat(sufficient.at("/data/reviewSummary/topTags/1").asText()).isEqualTo("visible-b"); + assertThat(sufficient.at("/data/reviewSummary/topTags/2").asText()).isEqualTo("visible-c"); + assertThat(sufficient.at( + "/data/reviewSummary/dimensionSummary/TEAM_ATMOSPHERE/averageScore").asDouble()) + .isEqualTo(4.0); + assertThat(sufficient.at( + "/data/reviewSummary/dimensionSummary/TEAM_ATMOSPHERE/sampleCount").asLong()) + .isEqualTo(3L); + } + + @Test + void anonymousRequestsAreUnauthorizedForEveryOnboardingPath() throws Exception { + Long companyUser = createUser(User.Role.COMPANY); + Long companyId = createCompany(companyUser, "匿名企业"); + OnboardingChecklistItem item = saveStandardItem("anonymous-standard", "通用项", 100); + + for (MockHttpServletRequestBuilder request : allOnboardingRequests(companyId, item.getId())) { + JsonNode body = performJson(request, 401); + assertThat(body.path("code").asInt()).isEqualTo(401); + } + } + + @Test + void wrongRolesAreForbiddenForEveryOnboardingPathWithoutStateChanges() throws Exception { + Long companyUser = createUser(User.Role.COMPANY); + Long companyId = createCompany(companyUser, "角色企业"); + OnboardingChecklistItem item = saveStandardItem("role-standard", "通用项", 100); + Long student = createUser(User.Role.STUDENT); + Long admin = createUser(User.Role.ADMIN); + + for (MockHttpServletRequestBuilder request : studentOnboardingRequests(companyId, item.getId())) { + JsonNode body = performJson(request.header( + HttpHeaders.AUTHORIZATION, bearer(companyUser)), 403); + assertThat(body.path("code").asInt()).isEqualTo(ErrorCode.FORBIDDEN.getCode()); + } + + for (MockHttpServletRequestBuilder request : companyOnboardingRequests(item.getId())) { + JsonNode body = performJson(request.header( + HttpHeaders.AUTHORIZATION, bearer(student)), 403); + assertThat(body.path("code").asInt()).isEqualTo(ErrorCode.FORBIDDEN.getCode()); + } + + for (MockHttpServletRequestBuilder request : allOnboardingRequests(companyId, item.getId())) { + JsonNode body = performJson(request.header( + HttpHeaders.AUTHORIZATION, bearer(admin)), 403); + assertThat(body.path("code").asInt()).isEqualTo(ErrorCode.FORBIDDEN.getCode()); + } + + assertThat(checklistItemRepository.count()).isEqualTo(1); + assertThat(checklistItemRepository.findById(item.getId()).orElseThrow().getActive()).isTrue(); + assertThat(progressRepository.count()).isZero(); + assertThat(knowledgeRepository.count()).isZero(); + } + + @Test + void notFoundResponsesDoNotCreateOnboardingRows() throws Exception { + Long student = createUser(User.Role.STUDENT); + Long companyUserWithoutProfile = createUser(User.Role.COMPANY); + + assertBusinessError(performJson(get( + "/onboarding/companies/{companyId}/checklist", 999_999L) + .header(HttpHeaders.AUTHORIZATION, bearer(student)), 404), + ErrorCode.COMPANY_NOT_FOUND); + + assertBusinessError(performJson(put( + "/onboarding/companies/{companyId}/checklist/items/{itemId}/completion", + 999_999L, 999_999L) + .header(HttpHeaders.AUTHORIZATION, bearer(student)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"completed\": true}"), 404), ErrorCode.COMPANY_NOT_FOUND); + + assertBusinessError(performJson(get( + "/onboarding/companies/{companyId}/knowledge", 999_999L) + .header(HttpHeaders.AUTHORIZATION, bearer(student)), 404), + ErrorCode.COMPANY_NOT_FOUND); + + for (MockHttpServletRequestBuilder request : companyOnboardingRequests(999_999L)) { + JsonNode body = performJson(request.header( + HttpHeaders.AUTHORIZATION, bearer(companyUserWithoutProfile)), 404); + assertThat(body.path("code").asInt()).isEqualTo(ErrorCode.COMPANY_NOT_FOUND.getCode()); + } + + Long companyOwner = createUser(User.Role.COMPANY); + Long companyId = createCompany(companyOwner, "缺失条目企业"); + assertBusinessError(performJson(completionRequest(companyId, 999_999L, student, + "{\"completed\":true}"), 404), + ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND); + assertBusinessError(performJson(put( + "/onboarding/company/checklist-items/{itemId}", 999_999L) + .header(HttpHeaders.AUTHORIZATION, bearer(companyOwner)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "不存在", + "category": "OTHER", + "sortOrder": 1, + "required": false, + "active": true, + "version": 0 + } + """), 404), ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND); + assertBusinessError(performJson(delete( + "/onboarding/company/checklist-items/{itemId}", 999_999L) + .param("version", "0") + .header(HttpHeaders.AUTHORIZATION, bearer(companyOwner)), 404), + ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND); + + assertThat(checklistItemRepository.count()).isZero(); + assertThat(progressRepository.count()).isZero(); + assertThat(knowledgeRepository.count()).isZero(); + } + + private JsonNode performJson(MockHttpServletRequestBuilder request, int expectedHttpStatus) + throws Exception { + MvcResult result = mockMvc.perform(request) + .andExpect(status().is(expectedHttpStatus)) + .andReturn(); + String body = result.getResponse().getContentAsString(); + assertSensitiveFieldsAbsent(body); + return objectMapper.readTree(body); + } + + private void assertBusinessError(JsonNode body, ErrorCode expectedCode) { + assertThat(body.path("code").asInt()).isEqualTo(expectedCode.getCode()); + } + + private void assertSensitiveFieldsAbsent(String body) { + assertThat(body).doesNotContain( + "\"ownerUserId\"", + "\"studentUserId\"", + "\"moderationReason\"", + "\"requestHash\"", + "\"contentHash\"", + "\"ipHash\"", + "\"uaHash\"", + "\"reviewHash\"", + "\"ipAddress\"", + "\"userAgent\"", + "\"passwordHash\"", + "\"idempotencyKey\"", + "\"token\":", + "\"accessToken\":", + "\"phone\":", + "\"email\":"); + } + + private JsonNode studentKnowledge(Long companyId, Long student, int status) throws Exception { + return performJson(get("/onboarding/companies/{companyId}/knowledge", companyId) + .header(HttpHeaders.AUTHORIZATION, bearer(student)), status); + } + + private MockHttpServletRequestBuilder completionRequest( + Long companyId, Long itemId, Long student, String body) { + return put("/onboarding/companies/{companyId}/checklist/items/{itemId}/completion", + companyId, itemId) + .header(HttpHeaders.AUTHORIZATION, bearer(student)) + .contentType(MediaType.APPLICATION_JSON) + .content(body); + } + + private List allOnboardingRequests(Long companyId, Long itemId) { + List requests = new ArrayList<>(); + requests.addAll(studentOnboardingRequests(companyId, itemId)); + requests.addAll(companyOnboardingRequests(itemId)); + return requests; + } + + private List studentOnboardingRequests( + Long companyId, Long itemId) { + return List.of( + get("/onboarding/companies/{companyId}/checklist", companyId), + put("/onboarding/companies/{companyId}/checklist/items/{itemId}/completion", + companyId, itemId) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"completed\":true,\"version\":0}"), + get("/onboarding/companies/{companyId}/knowledge", companyId)); + } + + private List companyOnboardingRequests(Long itemId) { + return List.of( + get("/onboarding/company/checklist-items"), + post("/onboarding/company/checklist-items") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "角色校验项", + "category": "OTHER", + "sortOrder": 1, + "required": false + } + """), + put("/onboarding/company/checklist-items/{itemId}", itemId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "更新项", + "category": "OTHER", + "sortOrder": 1, + "required": false, + "active": true, + "version": 0 + } + """), + delete("/onboarding/company/checklist-items/{itemId}", itemId) + .param("version", "0"), + get("/onboarding/company/knowledge"), + put("/onboarding/company/knowledge") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "culture": "角色校验" + } + """)); + } + + private OnboardingChecklistItem saveStandardItem( + String itemKey, String title, int sortOrder) { + return checklistItemRepository.saveAndFlush(OnboardingChecklistItem.builder() + .scopeType("STANDARD") + .scopeId(0L) + .companyId(null) + .itemKey(itemKey) + .title(title) + .description(title + "说明") + .category("DOCUMENT") + .sortOrder(sortOrder) + .required(true) + .active(true) + .build()); + } + + private void seedCompanyItems(Long companyId, int count, boolean active) { + List items = new ArrayList<>(); + for (int index = 0; index < count; index++) { + items.add(OnboardingChecklistItem.builder() + .scopeType("COMPANY") + .scopeId(companyId) + .companyId(companyId) + .itemKey("seed-" + companyId + "-" + ITEM_SEQ.getAndIncrement()) + .title("种子项-" + index) + .description("种子说明") + .category("OTHER") + .sortOrder(1000 + index) + .required(false) + .active(active) + .build()); + } + checklistItemRepository.saveAllAndFlush(items); + } + + private List activeCompanyItems(Long companyId) { + return checklistItemRepository.findAll().stream() + .filter(item -> companyId.equals(item.getCompanyId())) + .filter(item -> Boolean.TRUE.equals(item.getActive())) + .toList(); + } + + private Long createCompany(Long ownerUserId, String name) { + return companyRepository.save(Company.builder() + .ownerUserId(ownerUserId) + .name(name) + .logoUrl("https://example.com/logo.png") + .description("公司描述") + .industry("软件") + .website("https://example.com") + .address("上海") + .verified(true) + .build()).getId(); + } + + private Long createUser(User.Role role) { + return userRepository.save(User.builder() + .phone(String.valueOf(PHONE_SEQ.getAndIncrement())) + .nickname(role.name()) + .role(role) + .roles(Set.of(role)) + .active(true) + .verified(true) + .build()).getId(); + } + + private Long createJob(Long companyId, String title) { + return jobRepository.save(Job.builder() + .companyId(companyId) + .title(title) + .description("岗位描述") + .city("上海") + .employmentType("FULL_TIME") + .status("PUBLISHED") + .build()).getId(); + } + + private JobReview saveReview( + Long jobId, + Long studentUserId, + int rating, + String tags, + String status, + String moderationStatus) { + return reviewRepository.saveAndFlush(JobReview.builder() + .jobId(jobId) + .studentUserId(studentUserId) + .rating(rating) + .content("评价内容") + .tags(tags) + .status(status) + .moderationStatus(moderationStatus) + .build()); + } + + private void saveDimension(JobReview review, String dimensionCode, int score) { + dimensionScoreRepository.saveAndFlush(JobReviewDimensionScore.builder() + .reviewId(review.getId()) + .dimensionCode(dimensionCode) + .score(score) + .build()); + } + + private String bearer(Long userId) { + return "Bearer " + com.mydreamjob.integration.LxqTestSessionIssuer.issue(userId, "STUDENT", integrationSessions, integrationUsers, integrationJdbc); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingMigrationTest.java new file mode 100644 index 0000000..91f78ec --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingMigrationTest.java @@ -0,0 +1,157 @@ +package com.mydreamjob.onboarding; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.context.TestPropertySource; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_onboarding_migration;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@ActiveProfiles("integration-test") +class OnboardingMigrationTest { + + @Autowired + private DataSource dataSource; + + @MockBean + private RedisTemplate redisTemplate; + + @Test + void migrationCreatesOnboardingTablesColumnsConstraintsAndIndexes() throws SQLException { + assertTableAndColumns("onboarding_checklist_items", + "id", "scope_type", "scope_id", "company_id", "item_key", "title", + "description", "category", "sort_order", "required", "active", + "idempotency_key", "request_hash", "version", "created_at", "updated_at"); + assertTableAndColumns("student_onboarding_progress", + "id", "student_user_id", "item_id", "completed", "completed_at", + "version", "created_at", "updated_at"); + assertTableAndColumns("company_knowledge", + "id", "company_id", "culture", "work_policies", "onboarding_guide", + "version", "created_at", "updated_at"); + + assertUniqueIndex("onboarding_checklist_items", "scope_type", "scope_id", "item_key"); + assertUniqueIndex("onboarding_checklist_items", "company_id", "idempotency_key"); + assertUniqueIndex("student_onboarding_progress", "student_user_id", "item_id"); + assertUniqueIndex("company_knowledge", "company_id"); + + assertForeignKey("onboarding_checklist_items", "company_id", "companies", "id"); + assertForeignKey("student_onboarding_progress", "student_user_id", "users", "id"); + assertForeignKey("student_onboarding_progress", "item_id", "onboarding_checklist_items", "id"); + assertForeignKey("company_knowledge", "company_id", "companies", "id"); + + assertIndex("onboarding_checklist_items", + "scope_type", "scope_id", "active", "sort_order", "id"); + } + + private void assertTableAndColumns(String table, String... columns) throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualTable = findTable(metadata, table); + if (actualTable == null) { + throw new AssertionError("Missing table " + table); + } + List actualColumns = new ArrayList<>(); + try (ResultSet resultSet = metadata.getColumns(null, null, actualTable, null)) { + while (resultSet.next()) { + actualColumns.add(resultSet.getString("COLUMN_NAME").toLowerCase()); + } + } + for (String column : columns) { + assertTrue(actualColumns.contains(column.toLowerCase()), + "Missing column " + table + "." + column + "; actual=" + actualColumns); + } + } + } + + private void assertUniqueIndex(String table, String... columns) throws SQLException { + assertTrue(hasIndex(table, true, columns), + "Missing unique index on " + table + "(" + String.join(", ", columns) + ")"); + } + + private void assertIndex(String table, String... columns) throws SQLException { + assertTrue(hasIndex(table, false, columns), + "Missing index on " + table + "(" + String.join(", ", columns) + ")"); + } + + private boolean hasIndex(String table, boolean unique, String... expectedColumns) + throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualTable = findTable(metadata, table); + if (actualTable == null) { + return false; + } + Map> indexes = new LinkedHashMap<>(); + try (ResultSet resultSet = metadata.getIndexInfo(null, null, actualTable, false, false)) { + while (resultSet.next()) { + String indexName = resultSet.getString("INDEX_NAME"); + if (indexName == null || resultSet.getBoolean("NON_UNIQUE") == unique) { + continue; + } + indexes.computeIfAbsent(indexName, ignored -> new TreeMap<>()) + .put(resultSet.getShort("ORDINAL_POSITION"), + resultSet.getString("COLUMN_NAME").toLowerCase()); + } + } + List expected = new ArrayList<>(); + for (String column : expectedColumns) { + expected.add(column.toLowerCase()); + } + return indexes.values().stream() + .map(TreeMap::values) + .map(values -> new ArrayList<>(values)) + .anyMatch(expected::equals); + } + } + + private void assertForeignKey(String childTable, String childColumn, + String parentTable, String parentColumn) throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualChildTable = findTable(metadata, childTable); + String actualParentTable = findTable(metadata, parentTable); + if (actualChildTable == null || actualParentTable == null) { + throw new AssertionError("Missing table for foreign key " + childTable + "." + childColumn); + } + try (ResultSet resultSet = metadata.getImportedKeys(null, null, actualChildTable)) { + while (resultSet.next()) { + if (actualParentTable.equalsIgnoreCase(resultSet.getString("PKTABLE_NAME")) + && childColumn.equalsIgnoreCase(resultSet.getString("FKCOLUMN_NAME")) + && parentColumn.equalsIgnoreCase(resultSet.getString("PKCOLUMN_NAME"))) { + return; + } + } + } + } + throw new AssertionError("Missing foreign key " + childTable + "." + childColumn + + " -> " + parentTable + "." + parentColumn); + } + + private String findTable(DatabaseMetaData metadata, String expected) throws SQLException { + try (ResultSet resultSet = metadata.getTables(null, null, null, new String[]{"TABLE"})) { + while (resultSet.next()) { + if (expected.equalsIgnoreCase(resultSet.getString("TABLE_NAME"))) { + return resultSet.getString("TABLE_NAME"); + } + } + } + return null; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingPrivacyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingPrivacyTest.java new file mode 100644 index 0000000..12fe81b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingPrivacyTest.java @@ -0,0 +1,233 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:onboarding_privacy;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class OnboardingPrivacyTest { + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.AuthSessionService integrationSessions; + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.UserRepository integrationUsers; + @org.springframework.beans.factory.annotation.Autowired private org.springframework.jdbc.core.JdbcTemplate integrationJdbc; + @org.junit.jupiter.api.AfterEach void cleanupIntegrationSessions() { + com.mydreamjob.integration.LxqTestSessionIssuer.cleanup(integrationJdbc); + } + + + private static final AtomicLong PHONE_SEQ = new AtomicLong(973_000_000L); + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private OnboardingChecklistItemRepository checklistItemRepository; + @Autowired private StudentOnboardingProgressRepository progressRepository; + @Autowired private CompanyKnowledgeRepository knowledgeRepository; + + @MockBean private RedisTemplate redisTemplate; + + @BeforeEach + void clearData() { + progressRepository.deleteAll(); + knowledgeRepository.deleteAll(); + checklistItemRepository.deleteAll(); + companyRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + void companyChecklistResponseDoesNotLeakPersistenceOrIdempotencyFields() throws Exception { + Long companyUser = createUser(User.Role.COMPANY); + createCompany(companyUser, "隐私企业"); + + String body = mockMvc.perform(post("/onboarding/company/checklist-items") + .header("Authorization", bearer(companyUser)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "隐私测试清单项", + "description": "仅返回契约字段", + "category": "ADMIN", + "sortOrder": 100, + "required": false, + "idempotencyKey": "private-key" + } + """)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(body).contains("itemId", "source", "itemKey", "title", "category"); + assertSensitiveFieldsAbsent(body); + assertThat(body).doesNotContain("\"idempotencyKey\":", "\"requestHash\":", + "\"scopeId\":", "\"scopeType\":", "\"companyId\":"); + } + + @Test + void studentChecklistResponseDoesNotLeakStudentOrPersistenceIdentifiers() throws Exception { + Long companyUser = createUser(User.Role.COMPANY); + Long companyId = createCompany(companyUser, "学生隐私企业"); + Long student = createUser(User.Role.STUDENT); + OnboardingChecklistItem item = checklistItemRepository.save(OnboardingChecklistItem.builder() + .scopeType("STANDARD") + .scopeId(0L) + .companyId(null) + .itemKey("privacy-standard") + .title("隐私通用项") + .description("说明") + .category("DOCUMENT") + .sortOrder(10) + .required(true) + .active(true) + .build()); + progressRepository.save(StudentOnboardingProgress.builder() + .studentUserId(student) + .itemId(item.getId()) + .completed(true) + .completedAt(LocalDateTime.now()) + .build()); + + String body = mockMvc.perform(get("/onboarding/companies/{companyId}/checklist", companyId) + .header("Authorization", bearer(student))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(body).contains("companyId", "companyName", "items", "progressVersion"); + assertSensitiveFieldsAbsent(body); + assertThat(body).doesNotContain("\"studentUserId\":", "\"passwordHash\":", + "\"requestHash\":", "\"idempotencyKey\":"); + } + + @Test + void studentKnowledgeOverviewDoesNotLeakOwnerOrModerationInternals() throws Exception { + Long companyUser = createUser(User.Role.COMPANY); + Long companyId = createCompany(companyUser, "知识隐私企业"); + Long student = createUser(User.Role.STUDENT); + knowledgeRepository.save(CompanyKnowledge.builder() + .companyId(companyId) + .culture("文化") + .workPolicies("制度") + .onboardingGuide("指南") + .build()); + + String body = mockMvc.perform(get("/onboarding/companies/{companyId}/knowledge", companyId) + .header("Authorization", bearer(student))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(body).contains("knowledgeState", "company", "reviewSummary"); + assertSensitiveFieldsAbsent(body); + assertThat(body).doesNotContain("\"companyId\":", "\"ownerUserId\":", + "\"moderationReason\":", "\"reviewHash\":"); + } + + @Test + void crossCompanyErrorDoesNotEchoTargetContentOrHashes() throws Exception { + Long ownerA = createUser(User.Role.COMPANY); + Long companyA = createCompany(ownerA, "内容企业A"); + Long ownerB = createUser(User.Role.COMPANY); + createCompany(ownerB, "内容企业B"); + OnboardingChecklistItem item = checklistItemRepository.save(OnboardingChecklistItem.builder() + .scopeType("COMPANY") + .scopeId(companyA) + .companyId(companyA) + .itemKey("private-target-item") + .title("不可泄露的目标标题") + .description("不可泄露的说明") + .category("ADMIN") + .sortOrder(1) + .required(false) + .active(true) + .idempotencyKey("private-target-key") + .requestHash("b".repeat(64)) + .build()); + + String body = mockMvc.perform(put("/onboarding/company/checklist-items/{itemId}", item.getId()) + .header("Authorization", bearer(ownerB)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "越权请求", + "category": "ADMIN", + "sortOrder": 1, + "required": false, + "active": true, + "version": 0 + } + """)) + .andExpect(status().isNotFound()) + .andReturn().getResponse().getContentAsString(); + + assertThat(body).doesNotContain("不可泄露的目标标题", "不可泄露的说明", + "private-target-key", "b".repeat(64), "private-target-item"); + assertSensitiveFieldsAbsent(body); + } + + private void assertSensitiveFieldsAbsent(String body) { + assertThat(body).doesNotContain( + "ownerUserId", + "studentUserId", + "requestHash", + "idempotencyKey", + "moderationReason", + "passwordHash", + "ipAddress", + "userAgent", + "\"token\":", + "\"accessToken\":"); + } + + private Long createCompany(Long ownerUserId, String name) { + return companyRepository.save(Company.builder() + .ownerUserId(ownerUserId) + .name(name) + .description("公司描述") + .industry("软件") + .website("https://example.com") + .address("上海") + .verified(true) + .build()).getId(); + } + + private Long createUser(User.Role role) { + return userRepository.save(User.builder() + .phone(String.valueOf(PHONE_SEQ.getAndIncrement())) + .nickname(role.name()) + .role(role) + .roles(Set.of(role)) + .active(true) + .verified(true) + .build()).getId(); + } + + private String bearer(Long userId) { + return "Bearer " + com.mydreamjob.integration.LxqTestSessionIssuer.issue(userId, "STUDENT", integrationSessions, integrationUsers, integrationJdbc); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java new file mode 100644 index 0000000..c7ae2fc --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java @@ -0,0 +1,435 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.review.JobReview; +import com.mydreamjob.review.JobReviewDimensionScore; +import com.mydreamjob.review.JobReviewDimensionScoreRepository; +import com.mydreamjob.review.JobReviewRepository; +import org.hibernate.boot.model.relational.Namespace; +import org.hibernate.boot.model.relational.Sequence; +import org.hibernate.mapping.Table; +import org.junit.jupiter.api.BeforeEach; +import org.hibernate.tool.schema.spi.SchemaFilter; +import org.hibernate.tool.schema.spi.SchemaFilterProvider; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:integrationdb_onboarding_repository;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.hibernate.ddl-auto=validate", + "spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=com.mydreamjob.onboarding.OnboardingRepositoryIntegrationTest$OnboardingSchemaFilterProvider" +}) +@ActiveProfiles("integration-test") +@Transactional +class OnboardingRepositoryIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(900); + + @Autowired private OnboardingChecklistItemRepository checklistItemRepository; + @Autowired private StudentOnboardingProgressRepository progressRepository; + @Autowired private CompanyKnowledgeRepository knowledgeRepository; + @Autowired private JobReviewRepository reviewRepository; + @Autowired private JobReviewDimensionScoreRepository dimensionScoreRepository; + @Autowired private UserRepository userRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private JobRepository jobRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + @MockBean private RedisTemplate redisTemplate; + + @BeforeEach + void clearData() { + progressRepository.deleteAll(); + knowledgeRepository.deleteAll(); + dimensionScoreRepository.deleteAll(); + reviewRepository.deleteAll(); + jobRepository.deleteAll(); + checklistItemRepository.deleteAll(); + companyRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + void effectiveChecklistReturnsActiveStandardAndCompanyItemsInStableOrder() { + Long companyAId = createCompany("清单企业A"); + Long companyBId = createCompany("清单企业B"); + + OnboardingChecklistItem standardActive = saveChecklistItem( + "STANDARD", 0L, null, "standard-visible", "通用可见项", 100, true, null, null); + saveChecklistItem("STANDARD", 0L, null, "standard-inactive", "通用停用项", 1, false, null, null); + OnboardingChecklistItem companyFirst = saveChecklistItem( + "COMPANY", companyAId, companyAId, "company-a-first", "企业A首项", 50, true, + "company-a-first-key", "a".repeat(64)); + OnboardingChecklistItem companySecond = saveChecklistItem( + "COMPANY", companyAId, companyAId, "company-a-second", "企业A次项", 50, true, + "company-a-second-key", "b".repeat(64)); + saveChecklistItem("COMPANY", companyAId, companyAId, "company-a-inactive", "企业A停用项", + 40, false, null, null); + saveChecklistItem("COMPANY", companyBId, companyBId, "company-b-visible", "企业B可见项", + 10, true, "company-b-key", "c".repeat(64)); + + List result = checklistItemRepository.findEffectiveChecklistItems(companyAId); + + assertThat(result) + .extracting(OnboardingChecklistItem::getId) + .containsExactly(companyFirst.getId(), companySecond.getId(), standardActive.getId()); + assertThat(result) + .extracting(OnboardingChecklistItem::getItemKey) + .containsExactly("company-a-first", "company-a-second", "standard-visible"); + } + + @Test + void requestHashIsMappedAsChar64() { + Long companyId = createCompany("哈希企业"); + String hash = "0123456789abcdef".repeat(4); + OnboardingChecklistItem saved = saveChecklistItem( + "COMPANY", companyId, companyId, "company-hash", "哈希映射项", 200, true, + "company-hash-key", hash); + checklistItemRepository.flush(); + + assertThat(checklistItemRepository.findById(saved.getId())) + .get() + .extracting(OnboardingChecklistItem::getRequestHash) + .isEqualTo(hash); + + String typeName = jdbcTemplate.queryForObject( + "SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE UPPER(TABLE_NAME) = 'ONBOARDING_CHECKLIST_ITEMS' " + + "AND UPPER(COLUMN_NAME) = 'REQUEST_HASH'", + String.class); + Integer columnSize = jdbcTemplate.queryForObject( + "SELECT CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE UPPER(TABLE_NAME) = 'ONBOARDING_CHECKLIST_ITEMS' " + + "AND UPPER(COLUMN_NAME) = 'REQUEST_HASH'", + Integer.class); + + assertThat(typeName).isNotNull().startsWith("CHAR").doesNotContain("VARCHAR"); + assertThat(columnSize).isEqualTo(64); + } + + @Test + void progressBatchAndUniqueLookupsAreScopedToStudentAndItem() { + Long studentOne = createUser(User.Role.STUDENT, "进度学生A"); + Long studentTwo = createUser(User.Role.STUDENT, "进度学生B"); + OnboardingChecklistItem firstItem = saveChecklistItem( + "STANDARD", 0L, null, "progress-item-1", "进度项1", 10, true, null, null); + OnboardingChecklistItem secondItem = saveChecklistItem( + "STANDARD", 0L, null, "progress-item-2", "进度项2", 20, true, null, null); + OnboardingChecklistItem thirdItem = saveChecklistItem( + "STANDARD", 0L, null, "progress-item-3", "进度项3", 30, true, null, null); + + LocalDateTime completedAt = LocalDateTime.of(2026, 9, 16, 8, 0); + progressRepository.save(StudentOnboardingProgress.builder() + .studentUserId(studentOne).itemId(firstItem.getId()).completed(true) + .completedAt(completedAt).build()); + progressRepository.save(StudentOnboardingProgress.builder() + .studentUserId(studentOne).itemId(secondItem.getId()).completed(false) + .completedAt(null).build()); + progressRepository.save(StudentOnboardingProgress.builder() + .studentUserId(studentTwo).itemId(firstItem.getId()).completed(true) + .completedAt(LocalDateTime.of(2026, 9, 16, 9, 0)).build()); + progressRepository.flush(); + + List batch = progressRepository.findByStudentUserIdAndItemIdIn( + studentOne, List.of(firstItem.getId(), secondItem.getId(), thirdItem.getId())); + assertThat(batch) + .extracting(StudentOnboardingProgress::getItemId) + .containsExactlyInAnyOrder(firstItem.getId(), secondItem.getId()); + assertThat(batch) + .filteredOn(progress -> progress.getItemId().equals(firstItem.getId())) + .singleElement() + .extracting(StudentOnboardingProgress::getCompletedAt) + .isEqualTo(completedAt); + + Optional unique = progressRepository.findByStudentUserIdAndItemId( + studentOne, firstItem.getId()); + assertThat(unique).isPresent(); + assertThat(unique.orElseThrow().getCompleted()).isTrue(); + assertThat(progressRepository.findByStudentUserIdAndItemId(studentOne, thirdItem.getId())).isEmpty(); + assertThat(progressRepository.findByStudentUserIdAndItemId(studentTwo, secondItem.getId())).isEmpty(); + } + + @Test + void companyKnowledgeLookupIsScopedToCompany() { + Long companyId = createCompany("知识企业"); + Long otherCompanyId = createCompany("其他知识企业"); + knowledgeRepository.save(CompanyKnowledge.builder() + .companyId(companyId) + .culture("重视透明沟通") + .workPolicies("弹性工作制") + .onboardingGuide("前台报到") + .build()); + knowledgeRepository.flush(); + + Optional found = knowledgeRepository.findByCompanyId(companyId); + assertThat(found).isPresent(); + assertThat(found.orElseThrow().getCulture()).isEqualTo("重视透明沟通"); + assertThat(found.orElseThrow().getWorkPolicies()).isEqualTo("弹性工作制"); + assertThat(found.orElseThrow().getOnboardingGuide()).isEqualTo("前台报到"); + assertThat(knowledgeRepository.findByCompanyId(otherCompanyId)).isEmpty(); + } + + @Test + void companyReviewAggregatesIncludeOnlyPublishedApprovedReviews() { + Long companyId = createCompany("评价企业A"); + Long otherCompanyId = createCompany("评价企业B"); + Long studentOne = createUser(User.Role.STUDENT, "评价学生A"); + Long studentTwo = createUser(User.Role.STUDENT, "评价学生B"); + Long studentThree = createUser(User.Role.STUDENT, "评价学生C"); + Long hiddenStudent = createUser(User.Role.STUDENT, "评价学生D"); + Long otherStudent = createUser(User.Role.STUDENT, "评价学生E"); + + Long firstJobId = createJob(companyId, "评价岗位A1"); + Long secondJobId = createJob(companyId, "评价岗位A2"); + Long otherJobId = createJob(otherCompanyId, "评价岗位B"); + + JobReview five = saveReview(firstJobId, studentOne, 5, "[\"成长空间\",\"团队氛围\"]", + "PUBLISHED", JobReview.MODERATION_APPROVED); + JobReview four = saveReview(secondJobId, studentTwo, 4, "[\"成长空间\"]", + "PUBLISHED", JobReview.MODERATION_AUTO_APPROVED); + JobReview three = saveReview(firstJobId, studentThree, 3, "[\"团队氛围\"]", + "PUBLISHED", JobReview.MODERATION_APPROVED); + JobReview hidden = saveReview(firstJobId, hiddenStudent, 1, "[\"薪资透明\"]", + "PUBLISHED", JobReview.MODERATION_PENDING); + saveReview(otherJobId, otherStudent, 2, "[\"其他企业标签\"]", + "PUBLISHED", JobReview.MODERATION_APPROVED); + + dimensionScoreRepository.saveAll(List.of( + dimensionScore(five.getId(), "TEAM_ATMOSPHERE", 5), + dimensionScore(five.getId(), "GROWTH", 5), + dimensionScore(four.getId(), "TEAM_ATMOSPHERE", 4), + dimensionScore(four.getId(), "GROWTH", 4), + dimensionScore(three.getId(), "TEAM_ATMOSPHERE", 3), + dimensionScore(hidden.getId(), "TEAM_ATMOSPHERE", 1) + )); + dimensionScoreRepository.flush(); + + List summary = reviewRepository.countAndAveragePublishedRatingByCompanyId(companyId); + assertThat(summary).hasSize(1); + assertThat(((Number) summary.get(0)[0]).longValue()).isEqualTo(3); + assertThat(((Number) summary.get(0)[1]).doubleValue()).isEqualTo(4.0); + + List distribution = reviewRepository.findPublishedRatingDistributionByCompanyId(companyId); + assertThat(distribution).hasSize(3); + assertThat(((Number) distribution.get(0)[0]).intValue()).isEqualTo(3); + assertThat(((Number) distribution.get(0)[1]).longValue()).isEqualTo(1); + assertThat(((Number) distribution.get(1)[0]).intValue()).isEqualTo(4); + assertThat(((Number) distribution.get(1)[1]).longValue()).isEqualTo(1); + assertThat(((Number) distribution.get(2)[0]).intValue()).isEqualTo(5); + assertThat(((Number) distribution.get(2)[1]).longValue()).isEqualTo(1); + + List tags = reviewRepository.findPublishedTagsByCompanyId(companyId); + assertThat(tags).hasSize(3); + assertThat(tags).allSatisfy(tag -> + assertThat(tag).doesNotContain("薪资透明", "其他企业标签")); + assertThat(tags).anySatisfy(tag -> + assertThat(tag).contains("成长空间").contains("团队氛围")); + assertThat(tags).anySatisfy(tag -> + assertThat(tag).contains("成长空间").doesNotContain("团队氛围")); + assertThat(tags).anySatisfy(tag -> + assertThat(tag).contains("团队氛围").doesNotContain("成长空间")); + + List dimensions = dimensionScoreRepository.aggregatePublishedDimensionsByCompanyId(companyId); + assertThat(dimensions).hasSize(2); + assertThat(dimensions.get(0)[0]).isEqualTo("GROWTH"); + assertThat(((Number) dimensions.get(0)[1]).doubleValue()).isEqualTo(4.5); + assertThat(((Number) dimensions.get(0)[2]).longValue()).isEqualTo(2); + assertThat(dimensions.get(1)[0]).isEqualTo("TEAM_ATMOSPHERE"); + assertThat(((Number) dimensions.get(1)[1]).doubleValue()).isEqualTo(4.0); + assertThat(((Number) dimensions.get(1)[2]).longValue()).isEqualTo(3); + } + + @Test + void companyReviewAggregatesExcludeHiddenApprovedReviews() { + Long companyId = createCompany("隐藏评价企业"); + Long jobId = createJob(companyId, "隐藏评价岗位"); + + JobReview visibleFive = saveReview(jobId, createUser(User.Role.STUDENT, "可见学生A"), + 5, "[\"visible-five\"]", "PUBLISHED", JobReview.MODERATION_APPROVED); + JobReview visibleFour = saveReview(jobId, createUser(User.Role.STUDENT, "可见学生B"), + 4, "[\"visible-four\"]", "PUBLISHED", JobReview.MODERATION_AUTO_APPROVED); + JobReview hidden = saveReview(jobId, createUser(User.Role.STUDENT, "隐藏学生"), + 1, "[\"hidden-approved\"]", "HIDDEN", JobReview.MODERATION_APPROVED); + + dimensionScoreRepository.saveAll(List.of( + dimensionScore(visibleFive.getId(), "TEAM_ATMOSPHERE", 5), + dimensionScore(visibleFour.getId(), "TEAM_ATMOSPHERE", 4), + dimensionScore(hidden.getId(), "TEAM_ATMOSPHERE", 1) + )); + dimensionScoreRepository.flush(); + + List summary = reviewRepository.countAndAveragePublishedRatingByCompanyId(companyId); + assertThat(summary).hasSize(1); + assertThat(((Number) summary.get(0)[0]).longValue()).isEqualTo(2); + assertThat(((Number) summary.get(0)[1]).doubleValue()).isEqualTo(4.5); + + List tags = reviewRepository.findPublishedTagsByCompanyId(companyId); + assertThat(tags).hasSize(2); + assertThat(tags).allSatisfy(tag -> assertThat(tag).doesNotContain("hidden-approved")); + + List dimensions = dimensionScoreRepository + .aggregatePublishedDimensionsByCompanyId(companyId); + assertThat(dimensions).hasSize(1); + assertThat(dimensions.get(0)[0]).isEqualTo("TEAM_ATMOSPHERE"); + assertThat(((Number) dimensions.get(0)[1]).doubleValue()).isEqualTo(4.5); + assertThat(((Number) dimensions.get(0)[2]).longValue()).isEqualTo(2); + } + + private Long createCompany(String name) { + Long ownerId = createUser(User.Role.COMPANY, name + "负责人"); + return companyRepository.save(Company.builder() + .ownerUserId(ownerId) + .name(name) + .verified(true) + .build()).getId(); + } + + private Long createUser(User.Role role, String nickname) { + User user = userRepository.save(User.builder() + .phone("175" + String.format("%08d", PHONE_SEQ.incrementAndGet())) + .nickname(nickname) + .role(role) + .roles(Set.of(role)) + .active(true) + .verified(true) + .build()); + return user.getId(); + } + + private Long createJob(Long companyId, String title) { + return jobRepository.save(Job.builder() + .companyId(companyId) + .title(title) + .description("岗位描述") + .city("上海") + .employmentType("FULL_TIME") + .status("PUBLISHED") + .build()).getId(); + } + + private OnboardingChecklistItem saveChecklistItem( + String scopeType, + Long scopeId, + Long companyId, + String itemKey, + String title, + int sortOrder, + boolean active, + String idempotencyKey, + String requestHash) { + return checklistItemRepository.save(OnboardingChecklistItem.builder() + .scopeType(scopeType) + .scopeId(scopeId) + .companyId(companyId) + .itemKey(itemKey) + .title(title) + .description(title + "说明") + .category("OTHER") + .sortOrder(sortOrder) + .required(false) + .active(active) + .idempotencyKey(idempotencyKey) + .requestHash(requestHash) + .build()); + } + + private JobReview saveReview( + Long jobId, + Long studentUserId, + int rating, + String tags, + String status, + String moderationStatus) { + return reviewRepository.save(JobReview.builder() + .jobId(jobId) + .studentUserId(studentUserId) + .rating(rating) + .content("评价内容") + .tags(tags) + .status(status) + .moderationStatus(moderationStatus) + .build()); + } + + private JobReviewDimensionScore dimensionScore(Long reviewId, String dimensionCode, int score) { + return JobReviewDimensionScore.builder() + .reviewId(reviewId) + .dimensionCode(dimensionCode) + .score(score) + .build(); + } + + public static class OnboardingSchemaFilterProvider implements SchemaFilterProvider { + + private static final Set VALIDATED_TABLES = Set.of( + "onboarding_checklist_items", + "student_onboarding_progress", + "company_knowledge" + ); + + @Override + public SchemaFilter getCreateFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getDropFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getTruncatorFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getMigrateFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getValidateFilter() { + return new SchemaFilter() { + @Override + public boolean includeNamespace(Namespace namespace) { + return true; + } + + @Override + public boolean includeTable(Table table) { + return VALIDATED_TABLES.contains(table.getName().toLowerCase(Locale.ROOT)); + } + + @Override + public boolean includeSequence(Sequence sequence) { + return false; + } + }; + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingSeedDataIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingSeedDataIntegrationTest.java new file mode 100644 index 0000000..856f2b4 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingSeedDataIntegrationTest.java @@ -0,0 +1,105 @@ +package com.mydreamjob.onboarding; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_onboarding_seed;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@ActiveProfiles("integration-test") +class OnboardingSeedDataIntegrationTest { + + private static final Set SUPPORTED_CATEGORIES = Set.of( + "DOCUMENT", "ADMIN", "EQUIPMENT", "TRAINING", "LOGISTICS", "OTHER"); + + private static final List EXPECTED_ITEMS = List.of( + new ExpectedStandardItem("standard-id-card", "准备身份证件", "DOCUMENT", 100, true), + new ExpectedStandardItem("standard-education-proof", "准备学历学位证明", "DOCUMENT", 110, true), + new ExpectedStandardItem("standard-id-photo", "准备证件照", "DOCUMENT", 120, false), + new ExpectedStandardItem("standard-bank-account", "提供工资卡信息", "ADMIN", 200, true), + new ExpectedStandardItem("standard-emergency-contact", "填写紧急联系人", "ADMIN", 210, true), + new ExpectedStandardItem("standard-background-check", "完成背景核验", "ADMIN", 220, false), + new ExpectedStandardItem("standard-medical-check", "确认入职体检要求", "ADMIN", 230, false), + new ExpectedStandardItem("standard-equipment-request", "确认办公设备需求", "EQUIPMENT", 300, false), + new ExpectedStandardItem("standard-report-location", "确认报到地址和时间", "LOGISTICS", 400, true), + new ExpectedStandardItem("standard-commute-plan", "规划通勤路线", "LOGISTICS", 410, false), + new ExpectedStandardItem("standard-compliance-training", "完成合规与保密培训", "TRAINING", 500, true), + new ExpectedStandardItem("standard-onboarding-guide", "阅读入职指南", "TRAINING", 510, false) + ); + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockBean + private RedisTemplate redisTemplate; + + @Test + void standardChecklistSeedIsStableAndComplete() { + List> rows = jdbcTemplate.queryForList( + "SELECT item_key, title, category, sort_order, required, active, " + + "scope_type, scope_id, company_id " + + "FROM onboarding_checklist_items " + + "WHERE scope_type = 'STANDARD' ORDER BY sort_order ASC, id ASC"); + + assertEquals(EXPECTED_ITEMS.size(), rows.size()); + for (int index = 0; index < EXPECTED_ITEMS.size(); index++) { + ExpectedStandardItem expected = EXPECTED_ITEMS.get(index); + Map actual = rows.get(index); + + assertEquals(expected.itemKey(), actual.get("item_key")); + assertEquals(expected.title(), actual.get("title")); + assertEquals(expected.category(), actual.get("category")); + assertEquals(expected.sortOrder(), ((Number) actual.get("sort_order")).intValue()); + assertEquals(expected.required(), asBoolean(actual.get("required"))); + assertTrue(asBoolean(actual.get("active"))); + assertEquals("STANDARD", actual.get("scope_type")); + assertEquals(0L, ((Number) actual.get("scope_id")).longValue()); + assertNull(actual.get("company_id")); + assertTrue(SUPPORTED_CATEGORIES.contains(expected.category())); + assertNotNull(actual.get("title")); + } + + assertEquals(EXPECTED_ITEMS.size(), count( + "SELECT COUNT(DISTINCT item_key) FROM onboarding_checklist_items " + + "WHERE scope_type = 'STANDARD'")); + } + + private int count(String sql) { + Integer count = jdbcTemplate.queryForObject(sql, Integer.class); + return count == null ? 0 : count; + } + + private boolean asBoolean(Object value) { + if (value instanceof Boolean booleanValue) { + return booleanValue; + } + if (value instanceof Number numberValue) { + return numberValue.intValue() != 0; + } + return Boolean.parseBoolean(String.valueOf(value)); + } + + private record ExpectedStandardItem( + String itemKey, + String title, + String category, + int sortOrder, + boolean required + ) { + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingVerifiedCompanyGateTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingVerifiedCompanyGateTest.java new file mode 100644 index 0000000..e971a7f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingVerifiedCompanyGateTest.java @@ -0,0 +1,46 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.review.JobReviewRepository; +import com.mydreamjob.review.JobReviewDimensionScoreRepository; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.Optional; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class OnboardingVerifiedCompanyGateTest { + @ParameterizedTest @NullSource @ValueSource(booleans={false}) + void unverifiedChecklistManagementRejectsBeforeBusinessRead(Boolean verified) { + var users=mock(UserRepository.class); + var companies=mock(CompanyRepository.class); + var items=mock(OnboardingChecklistItemRepository.class); + var progress=mock(StudentOnboardingProgressRepository.class); + when(users.findById(91L)).thenReturn(Optional.of(User.builder().id(91L).role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)).build())); + when(companies.findByOwnerUserId(91L)).thenReturn(Optional.of(Company.builder().id(92L).ownerUserId(91L).verified(verified).build())); + var service=new OnboardingChecklistService(users,companies,items,progress); + assertEquals(ErrorCode.COMPANY_NOT_VERIFIED.getCode(),assertThrows(BusinessException.class,()->service.listCompanyChecklistItems(91L)).getErrorCodeInt()); + verifyNoInteractions(items,progress); + } + + @ParameterizedTest @NullSource @ValueSource(booleans={false}) + void unverifiedKnowledgeManagementRejectsBeforeBusinessRead(Boolean verified) { + var users=mock(UserRepository.class); + var companies=mock(CompanyRepository.class); + var knowledge=mock(CompanyKnowledgeRepository.class); + var reviews=mock(JobReviewRepository.class); + var dimensions=mock(JobReviewDimensionScoreRepository.class); + when(users.findById(91L)).thenReturn(Optional.of(User.builder().id(91L).role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)).build())); + when(companies.findByOwnerUserId(91L)).thenReturn(Optional.of(Company.builder().id(92L).ownerUserId(91L).verified(verified).build())); + var service=new CompanyKnowledgeService(users,companies,knowledge,reviews,dimensions); + assertEquals(ErrorCode.COMPANY_NOT_VERIFIED.getCode(),assertThrows(BusinessException.class,()->service.getCompanyKnowledge(91L)).getErrorCodeInt()); + verifyNoInteractions(knowledge,reviews,dimensions); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAccountDeletionIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAccountDeletionIntegrationTest.java new file mode 100644 index 0000000..4b9eb03 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAccountDeletionIntegrationTest.java @@ -0,0 +1,81 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.deletion.AccountDeletion; +import com.mydreamjob.deletion.AccountDeletionCompletionWorker; +import com.mydreamjob.deletion.AccountDeletionRepository; +import com.mydreamjob.deletion.DeletionStatus; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_salary_deletion;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class SalaryAccountDeletionIntegrationTest { + + private static final AtomicLong PHONE = new AtomicLong(970_000_000L); + + @Autowired private UserRepository userRepository; + @Autowired private SalaryCalculationHistoryRepository historyRepository; + @Autowired private AccountDeletionRepository accountDeletionRepository; + @Autowired private AccountDeletionCompletionWorker completionWorker; + + @MockBean private RedisTemplate redisTemplate; + + @Test + void completedDeletionPurgesSalaryHistory() { + User user = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .roles(Set.of(User.Role.STUDENT)) + .role(User.Role.STUDENT) + .active(true) + .build()); + historyRepository.save(history(user.getId())); + accountDeletionRepository.save(AccountDeletion.builder() + .userId(user.getId()) + .status(DeletionStatus.PENDING) + .requestedAt(LocalDateTime.now().minusDays(31)) + .effectiveAt(LocalDateTime.now().minusMinutes(1)) + .version(0L) + .build()); + + assertThat(completionWorker.completeIfExpired(user.getId())).isTrue(); + + assertThat(historyRepository.findByStudentUserIdOrderByCreatedAtDescIdDesc( + user.getId(), PageRequest.of(0, 20)).getTotalElements()).isZero(); + assertThat(userRepository.findById(user.getId()).orElseThrow().getPhone()) + .startsWith("deleted_"); + } + + private SalaryCalculationHistory history(Long userId) { + return SalaryCalculationHistory.builder() + .studentUserId(userId) + .cityCode("110000") + .cityName("北京市") + .dataLevel(SalaryDataLevel.CITY) + .policyVersion("2026-09-20-v1") + .taxRulesVersion("CN_IIT_2019_V1") + .monthIndex(1) + .grossMonthlySalary(new BigDecimal("10000.00")) + .netSalary(new BigDecimal("7667.50")) + .inputSnapshotJson("{}") + .policySnapshotJson("{}") + .resultSnapshotJson("{}") + .build(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAuthorizationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAuthorizationIntegrationTest.java new file mode 100644 index 0000000..13074d7 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAuthorizationIntegrationTest.java @@ -0,0 +1,88 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.Test; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_salary_auth;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class SalaryAuthorizationIntegrationTest { + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.AuthSessionService integrationSessions; + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.UserRepository integrationUsers; + @org.springframework.beans.factory.annotation.Autowired private org.springframework.jdbc.core.JdbcTemplate integrationJdbc; + @org.junit.jupiter.api.AfterEach void cleanupIntegrationSessions() { + com.mydreamjob.integration.LxqTestSessionIssuer.cleanup(integrationJdbc); + } + + + private static final AtomicLong PHONE = new AtomicLong(980_000_000L); + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private SalaryCalculationHistoryRepository historyRepository; + + @MockBean private RedisTemplate redisTemplate; + + @Test + void anonymousCannotUseSalaryCalculator() throws Exception { + mockMvc.perform(get("/salary-calculator/calculations")) + .andExpect(status().isUnauthorized()); + } + + @Test + void companyCannotCalculateOrReadHistory() throws Exception { + User company = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .roles(Set.of(User.Role.COMPANY)) + .role(User.Role.COMPANY) + .active(true) + .build()); + String token = token(company.getId()); + + String body = "{\"cityCode\":\"110000\",\"grossMonthlySalary\":10000," + + "\"monthIndex\":1,\"specialAdditionalDeduction\":0," + + "\"expectedPolicyVersion\":\"2026-09-20-v1\"}"; + + mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + + mockMvc.perform(get("/salary-calculator/calculations") + .header("Authorization", token)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + + assertThat(historyRepository.count()).isZero(); + } + + private String token(long userId) { + return "Bearer " + com.mydreamjob.integration.LxqTestSessionIssuer.issue(userId, "COMPANY", integrationSessions, integrationUsers, integrationJdbc); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEndToEndIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEndToEndIntegrationTest.java new file mode 100644 index 0000000..512af33 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEndToEndIntegrationTest.java @@ -0,0 +1,174 @@ +package com.mydreamjob.salarycalculator; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.Test; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_salary_e2e;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class SalaryCalculationEndToEndIntegrationTest { + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.AuthSessionService integrationSessions; + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.UserRepository integrationUsers; + @org.springframework.beans.factory.annotation.Autowired private org.springframework.jdbc.core.JdbcTemplate integrationJdbc; + @org.junit.jupiter.api.AfterEach void cleanupIntegrationSessions() { + com.mydreamjob.integration.LxqTestSessionIssuer.cleanup(integrationJdbc); + } + + + private static final AtomicLong PHONE = new AtomicLong(960_000_000L); + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private SalaryCalculationHistoryRepository historyRepository; + private final ObjectMapper mapper = new ObjectMapper(); + + @MockBean private RedisTemplate redisTemplate; + + @Test + void calculateReplayConflictHistoryAndDelete() throws Exception { + long uid = createUser(User.Role.STUDENT); + String token = token(uid); + String request = requestJson("110000", "10000", 1, "0", "key-1"); + + String first = mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.result.cityCode").value("110000")) + .andExpect(jsonPath("$.data.result.dataLevel").value("CITY")) + .andExpect(jsonPath("$.data.result.estimate").value(true)) + .andExpect(jsonPath("$.data.result.netSalary").value(7667.50)) + .andReturn().getResponse().getContentAsString(); + + long historyId = mapper.readTree(first).path("data").path("historyId").asLong(); + assertThat(historyRepository.count()).isEqualTo(1); + + String replay = mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + assertThat(mapper.readTree(replay).path("data").path("historyId").asLong()) + .isEqualTo(historyId); + assertThat(historyRepository.count()).isEqualTo(1); + + mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson("110000", "12000", 1, "0", "key-1"))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(10604)); + + mockMvc.perform(get("/salary-calculator/calculations") + .header("Authorization", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.items[0].id").value(historyId)); + + mockMvc.perform(get("/salary-calculator/calculations/{id}", historyId) + .header("Authorization", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.inputSnapshot.cityCode").value("110000")) + .andExpect(jsonPath("$.data.policySnapshot.dataLevel").value("CITY")) + .andExpect(jsonPath("$.data.resultSnapshot.netSalary").value(7667.50)); + + long otherId = createUser(User.Role.STUDENT); + mockMvc.perform(get("/salary-calculator/calculations/{id}", historyId) + .header("Authorization", token(otherId))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(10603)); + + mockMvc.perform(delete("/salary-calculator/calculations/{id}", historyId) + .header("Authorization", token)) + .andExpect(status().isOk()); + assertThat(historyRepository.findById(historyId)).isEmpty(); + } + + @Test + void policyVersionConflictReturnsLatestVersionWithoutHistory() throws Exception { + long uid = createUser(User.Role.STUDENT); + String request = requestJson("110000", "10000", 1, "0", "version-key") + .replace("2026-09-20-v1", "stale-version"); + + mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(10605)) + .andExpect(jsonPath("$.data.latestPolicyVersion").value("2026-09-20-v1")) + .andExpect(jsonPath("$.data.policyEffectiveDate").value("2026-07-01")); + + assertThat(historyRepository.count()).isZero(); + } + + @Test + void invalidOverrideReturns10602() throws Exception { + long uid = createUser(User.Role.STUDENT); + String request = "{\"cityCode\":\"110000\",\"grossMonthlySalary\":10000," + + "\"monthIndex\":1,\"specialAdditionalDeduction\":0," + + "\"expectedPolicyVersion\":\"2026-09-20-v1\"," + + "\"idempotencyKey\":\"bad-override\"," + + "\"overrides\":{\"socialSecurityBase\":1000}}"; + + mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(10602)); + } + + private String requestJson( + String cityCode, String gross, int monthIndex, String special, String idempotencyKey) { + return "{\"cityCode\":\"" + cityCode + "\"," + + "\"grossMonthlySalary\":" + gross + "," + + "\"monthIndex\":" + monthIndex + "," + + "\"specialAdditionalDeduction\":" + special + "," + + "\"expectedPolicyVersion\":\"2026-09-20-v1\"," + + "\"idempotencyKey\":\"" + idempotencyKey + "\"," + + "\"overrides\":null}"; + } + + private long createUser(User.Role role) { + User user = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .roles(Set.of(role)) + .role(role) + .active(true) + .build()); + return user.getId(); + } + + private String token(long userId) { + return "Bearer " + com.mydreamjob.integration.LxqTestSessionIssuer.issue(userId, "STUDENT", integrationSessions, integrationUsers, integrationJdbc); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEngineTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEngineTest.java new file mode 100644 index 0000000..d783f52 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEngineTest.java @@ -0,0 +1,148 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SalaryCalculationEngineTest { + + private final SalaryCalculationEngine engine = new SalaryCalculationEngine(); + + @Test + void calculatesBaseCaseWithCumulativeWithholding() { + SalaryCalculationResult result = engine.calculate(input("10000", 1, "0", null), policy()); + + assertThat(result.getSocialSecurityBase()).isEqualByComparingTo("10000.00"); + assertThat(result.getHousingFundBase()).isEqualByComparingTo("10000.00"); + assertThat(result.getPension()).isEqualByComparingTo("800.00"); + assertThat(result.getMedical()).isEqualByComparingTo("200.00"); + assertThat(result.getUnemployment()).isEqualByComparingTo("50.00"); + assertThat(result.getHousingFund()).isEqualByComparingTo("1200.00"); + assertThat(result.getCurrentMonthTax()).isEqualByComparingTo("82.50"); + assertThat(result.getNetSalary()).isEqualByComparingTo("7667.50"); + assertThat(result.isEstimate()).isTrue(); + assertThat(result.getDataLevel()).isEqualTo(SalaryDataLevel.CITY); + } + + @Test + void clampsBothBasesToFloorAndCeiling() { + SalaryCalculationResult low = engine.calculate(input("3000", 1, "0", null), policy()); + SalaryCalculationResult high = engine.calculate(input("30000", 1, "0", null), policy()); + + assertThat(low.getSocialSecurityBase()).isEqualByComparingTo("5000.00"); + assertThat(low.getHousingFundBase()).isEqualByComparingTo("5000.00"); + assertThat(low.getCurrentMonthTax()).isEqualByComparingTo("0.00"); + assertThat(low.getNetSalary()).isEqualByComparingTo("1875.00"); + + assertThat(high.getSocialSecurityBase()).isEqualByComparingTo("20000.00"); + assertThat(high.getHousingFundBase()).isEqualByComparingTo("20000.00"); + assertThat(high.getCurrentMonthTax()).isEqualByComparingTo("615.00"); + assertThat(high.getNetSalary()).isEqualByComparingTo("24885.00"); + } + + @Test + void usesPriorCumulativeTaxForTwelfthMonth() { + SalaryCalculationResult result = engine.calculate(input("20000", 12, "0", null), policy()); + + assertThat(result.getCumulativeTaxableIncome()).isEqualByComparingTo("126000.00"); + assertThat(result.getCumulativeTaxDue()).isEqualByComparingTo("10080.00"); + assertThat(result.getPreviouslyWithheldTax()).isEqualByComparingTo("9030.00"); + assertThat(result.getCurrentMonthTax()).isEqualByComparingTo("1050.00"); + assertThat(result.getNetSalary()).isEqualByComparingTo("14450.00"); + } + + @Test + void appliesSpecialDeductionAndOverrides() { + SalaryOverrideParameters overrides = SalaryOverrideParameters.builder() + .socialSecurityBase(new BigDecimal("8000.00")) + .housingFundBase(new BigDecimal("10000.00")) + .pensionRate(new BigDecimal("0.08")) + .medicalRate(new BigDecimal("0.02")) + .unemploymentRate(new BigDecimal("0.005")) + .housingFundRate(new BigDecimal("0.12")) + .seriousIllnessMonthlyFee(BigDecimal.ZERO) + .build(); + + SalaryCalculationResult result = engine.calculate( + input("10000", 1, "2000", overrides), policy()); + + assertThat(result.getSocialSecurityBase()).isEqualByComparingTo("8000.00"); + assertThat(result.getPension()).isEqualByComparingTo("640.00"); + assertThat(result.getCurrentMonthTax()).isEqualByComparingTo("28.80"); + assertThat(result.getNetSalary()).isEqualByComparingTo("7931.20"); + } + + @Test + void rejectsOverrideOutsideCityBaseRange() { + SalaryOverrideParameters overrides = SalaryOverrideParameters.builder() + .socialSecurityBase(new BigDecimal("4999.99")) + .build(); + + assertThatThrownBy(() -> engine.calculate(input("10000", 1, "0", overrides), policy())) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.SALARY_INVALID_PARAMETER.getCode()); + } + + @Test + void zeroContributionRateAllowsZeroBase() { + SalaryOverrideParameters overrides = SalaryOverrideParameters.builder() + .socialSecurityBase(BigDecimal.ZERO) + .housingFundBase(BigDecimal.ZERO) + .pensionRate(BigDecimal.ZERO) + .medicalRate(BigDecimal.ZERO) + .unemploymentRate(BigDecimal.ZERO) + .housingFundRate(BigDecimal.ZERO) + .seriousIllnessMonthlyFee(BigDecimal.ZERO) + .build(); + + SalaryCalculationResult result = engine.calculate( + input("10000", 1, "0", overrides), policy()); + + assertThat(result.getPension()).isEqualByComparingTo("0.00"); + assertThat(result.getMedical()).isEqualByComparingTo("0.00"); + assertThat(result.getUnemployment()).isEqualByComparingTo("0.00"); + assertThat(result.getHousingFund()).isEqualByComparingTo("0.00"); + assertThat(result.getCurrentMonthTax()).isEqualByComparingTo("150.00"); + assertThat(result.getNetSalary()).isEqualByComparingTo("9850.00"); + } + + private SalaryCalculationInput input( + String gross, int monthIndex, String specialDeduction, + SalaryOverrideParameters overrides) { + return SalaryCalculationInput.builder() + .grossMonthlySalary(new BigDecimal(gross)) + .monthIndex(monthIndex) + .specialAdditionalDeduction(new BigDecimal(specialDeduction)) + .overrides(overrides) + .build(); + } + + private SalaryCityPolicy policy() { + return SalaryCityPolicy.builder() + .cityCode("110000") + .provinceCode("110000") + .provinceName("北京市") + .cityName("北京市") + .policyVersion("2026-09-20-v1") + .policyEffectiveDate(LocalDate.of(2026, 7, 1)) + .socialSecurityBaseMin(new BigDecimal("5000.00")) + .socialSecurityBaseMax(new BigDecimal("20000.00")) + .housingFundBaseMin(new BigDecimal("5000.00")) + .housingFundBaseMax(new BigDecimal("20000.00")) + .pensionRate(new BigDecimal("0.080000")) + .medicalRate(new BigDecimal("0.020000")) + .unemploymentRate(new BigDecimal("0.005000")) + .housingFundRate(new BigDecimal("0.120000")) + .seriousIllnessMonthlyFee(BigDecimal.ZERO) + .dataLevel(SalaryDataLevel.CITY) + .enabled(true) + .build(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyControllerTest.java new file mode 100644 index 0000000..d86e3d9 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyControllerTest.java @@ -0,0 +1,113 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.Test; +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.MockMvc; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class SalaryCityPolicyControllerTest { + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.AuthSessionService integrationSessions; + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.UserRepository integrationUsers; + @org.springframework.beans.factory.annotation.Autowired private org.springframework.jdbc.core.JdbcTemplate integrationJdbc; + @org.junit.jupiter.api.AfterEach void cleanupIntegrationSessions() { + com.mydreamjob.integration.LxqTestSessionIssuer.cleanup(integrationJdbc); + } + + + private static final AtomicLong PHONE = new AtomicLong(950_000_000L); + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + + @MockBean private RedisTemplate redisTemplate; + + @Test + void cities_returnsAllSeededCitiesForStudent() throws Exception { + long uid = createUser(User.Role.STUDENT); + + mockMvc.perform(get("/salary-calculator/cities") + .header("Authorization", token(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.length()").value(359)) + .andExpect(jsonPath("$.data[0].cityCode").value("110000")) + .andExpect(jsonPath("$.data[0].dataLevel").value("CITY")); + } + + @Test + void cityDetail_returnsDefaultsAndFallbackLevel() throws Exception { + long uid = createUser(User.Role.STUDENT); + + mockMvc.perform(get("/salary-calculator/cities/110000") + .header("Authorization", token(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.cityCode").value("110000")) + .andExpect(jsonPath("$.data.dataLevel").value("CITY")) + .andExpect(jsonPath("$.data.socialSecurityBaseMin").value(7270.00)) + .andExpect(jsonPath("$.data.housingFundRate").value(0.120000)); + + mockMvc.perform(get("/salary-calculator/cities/130100") + .header("Authorization", token(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.dataLevel").value("PROVINCE_FALLBACK")); + } + + @Test + void missingCity_returns404And10601() throws Exception { + long uid = createUser(User.Role.STUDENT); + + mockMvc.perform(get("/salary-calculator/cities/999999") + .header("Authorization", token(uid))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(10601)); + } + + @Test + void companyUser_returns403() throws Exception { + long uid = createUser(User.Role.COMPANY); + + mockMvc.perform(get("/salary-calculator/cities") + .header("Authorization", token(uid))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + @Test + void anonymous_returns401() throws Exception { + mockMvc.perform(get("/salary-calculator/cities")) + .andExpect(status().isUnauthorized()); + } + + private long createUser(User.Role role) { + User user = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .roles(Set.of(role)) + .role(role) + .active(true) + .build()); + return user.getId(); + } + + private String token(long userId) { + return "Bearer " + com.mydreamjob.integration.LxqTestSessionIssuer.issue(userId, "STUDENT", integrationSessions, integrationUsers, integrationJdbc); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicySeedTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicySeedTest.java new file mode 100644 index 0000000..2eb2959 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicySeedTest.java @@ -0,0 +1,84 @@ +package com.mydreamjob.salarycalculator; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.context.TestPropertySource; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_salary_seed;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@ActiveProfiles("integration-test") +class SalaryCityPolicySeedTest { + + private static final int EXPECTED_CITY_COUNT = 359; + private static final LocalDate CUTOFF_DATE = LocalDate.of(2026, 9, 20); + + private static final Set PROVINCE_DIRECT_CITY_CODES = Set.of( + "419001", "429004", "429005", "429006", + "469001", "469002", "469005", "469006", "469007", + "659001", "659002", "659003", "659004", "659005", + "659006", "659007", "659008", "659009", "659010", + "659011", "659012"); + + @Autowired + private SalaryCityPolicyRepository repository; + + @MockBean + private RedisTemplate redisTemplate; + + @Test + void seedCoversAllTargetCitiesWithLayeredDataLevels() { + List policies = repository.findAll(); + + assertThat(policies).hasSize(EXPECTED_CITY_COUNT); + assertThat(policies).extracting(SalaryCityPolicy::getCityCode).doesNotHaveDuplicates(); + assertThat(policies).extracting(SalaryCityPolicy::getCityCode) + .contains("110000", "120000", "310000", "500000"); + assertThat(policies).extracting(SalaryCityPolicy::getCityCode) + .containsAll(PROVINCE_DIRECT_CITY_CODES); + assertThat(policies).filteredOn(p -> p.getDataLevel() == SalaryDataLevel.CITY) + .hasSizeGreaterThanOrEqualTo(12); + assertThat(policies).filteredOn(p -> p.getDataLevel() == SalaryDataLevel.PROVINCE_FALLBACK) + .isNotEmpty(); + + for (SalaryCityPolicy p : policies) { + assertThat(p.getCityCode()).matches("\\d{6}"); + assertThat(p.getProvinceCode()).matches("\\d{6}"); + assertThat(p.getProvinceName()).isNotBlank(); + assertThat(p.getCityName()).isNotBlank(); + assertThat(p.getPolicyVersion()).isNotBlank(); + assertThat(p.getPolicyEffectiveDate()).isNotNull().isBeforeOrEqualTo(CUTOFF_DATE); + assertThat(p.getDataLevel()).isNotNull(); + assertThat(p.getEnabled()).isTrue(); + assertValidRange(p.getSocialSecurityBaseMin(), p.getSocialSecurityBaseMax()); + assertValidRange(p.getHousingFundBaseMin(), p.getHousingFundBaseMax()); + assertRate(p.getPensionRate(), new BigDecimal("0.20")); + assertRate(p.getMedicalRate(), new BigDecimal("0.20")); + assertRate(p.getUnemploymentRate(), new BigDecimal("0.10")); + assertRate(p.getHousingFundRate(), new BigDecimal("0.12")); + assertThat(p.getSeriousIllnessMonthlyFee()).isNotNull() + .isBetween(BigDecimal.ZERO, new BigDecimal("10000.00")); + } + } + + private void assertValidRange(BigDecimal min, BigDecimal max) { + assertThat(min).isNotNull().isGreaterThanOrEqualTo(BigDecimal.ZERO); + assertThat(max).isNotNull().isGreaterThanOrEqualTo(min); + } + + private void assertRate(BigDecimal rate, BigDecimal max) { + assertThat(rate).isNotNull().isGreaterThanOrEqualTo(BigDecimal.ZERO) + .isLessThanOrEqualTo(max); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyServiceTest.java new file mode 100644 index 0000000..bf2fe8d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyServiceTest.java @@ -0,0 +1,106 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.salarycalculator.dto.SalaryCityDetailResponse; +import com.mydreamjob.salarycalculator.dto.SalaryCitySummaryResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SalaryCityPolicyServiceTest { + + @Mock private SalaryCityPolicyRepository cityRepository; + @Mock private UserRepository userRepository; + @InjectMocks private SalaryCityPolicyService service; + + @Test + void listCities_returnsStableEnabledSummariesForStudent() { + when(userRepository.findById(10L)).thenReturn(Optional.of(user(10L, User.Role.STUDENT))); + when(cityRepository.findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc()) + .thenReturn(List.of(policy("110000", "北京市", SalaryDataLevel.CITY), + policy("130100", "石家庄市", SalaryDataLevel.PROVINCE_FALLBACK))); + + List result = service.listCities(10L); + + assertThat(result).extracting(SalaryCitySummaryResponse::getCityCode) + .containsExactly("110000", "130100"); + assertThat(result).extracting(SalaryCitySummaryResponse::getDataLevel) + .containsExactly(SalaryDataLevel.CITY, SalaryDataLevel.PROVINCE_FALLBACK); + } + + @Test + void getCity_returnsDefaultsAndDataLevel() { + when(userRepository.findById(10L)).thenReturn(Optional.of(user(10L, User.Role.STUDENT))); + when(cityRepository.findByCityCodeAndEnabledTrue("110000")) + .thenReturn(Optional.of(policy("110000", "北京市", SalaryDataLevel.CITY))); + + SalaryCityDetailResponse result = service.getCity(10L, "110000"); + + assertThat(result.getCityCode()).isEqualTo("110000"); + assertThat(result.getDataLevel()).isEqualTo(SalaryDataLevel.CITY); + assertThat(result.getSocialSecurityBaseMin()).isEqualByComparingTo("5000.00"); + } + + @Test + void nonStudent_isForbidden() { + when(userRepository.findById(10L)).thenReturn(Optional.of(user(10L, User.Role.COMPANY))); + + assertThatThrownBy(() -> service.listCities(10L)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.FORBIDDEN.getCode()); + } + + @Test + void missingOrDisabledCity_returns9801() { + when(userRepository.findById(10L)).thenReturn(Optional.of(user(10L, User.Role.STUDENT))); + when(cityRepository.findByCityCodeAndEnabledTrue("999999")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getCity(10L, "999999")) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.SALARY_CITY_NOT_FOUND.getCode()); + } + + private User user(Long id, User.Role role) { + return User.builder().id(id).roles(Set.of(role)).role(role).build(); + } + + private SalaryCityPolicy policy(String code, String name, SalaryDataLevel level) { + return SalaryCityPolicy.builder() + .cityCode(code) + .provinceCode("110000") + .provinceName("测试省") + .cityName(name) + .policyVersion("2026-09-20-v1") + .policyEffectiveDate(LocalDate.of(2026, 7, 1)) + .socialSecurityBaseMin(new BigDecimal("5000.00")) + .socialSecurityBaseMax(new BigDecimal("25000.00")) + .housingFundBaseMin(new BigDecimal("2000.00")) + .housingFundBaseMax(new BigDecimal("30000.00")) + .pensionRate(new BigDecimal("0.080000")) + .medicalRate(new BigDecimal("0.020000")) + .unemploymentRate(new BigDecimal("0.005000")) + .housingFundRate(new BigDecimal("0.120000")) + .seriousIllnessMonthlyFee(BigDecimal.ZERO) + .dataLevel(level) + .enabled(true) + .build(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryMigrationTest.java new file mode 100644 index 0000000..2edfa1a --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryMigrationTest.java @@ -0,0 +1,150 @@ +package com.mydreamjob.salarycalculator; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.context.TestPropertySource; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_salary_migration;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@ActiveProfiles("integration-test") +class SalaryMigrationTest { + + @Autowired + private DataSource dataSource; + + @MockBean + private RedisTemplate redisTemplate; + + @Test + void migrationCreatesSalaryPolicyAndHistoryTables() throws SQLException { + assertTableAndColumns("salary_city_policy", + "city_code", "province_code", "province_name", "city_name", "policy_version", + "policy_effective_date", "data_source", "social_security_base_min", + "social_security_base_max", "housing_fund_base_min", "housing_fund_base_max", + "pension_rate", "medical_rate", "unemployment_rate", "housing_fund_rate", + "serious_illness_monthly_fee", "data_level", "enabled", "created_at", "updated_at"); + assertTableAndColumns("salary_calculation_history", + "id", "student_user_id", "city_code", "city_name", "data_level", "policy_version", + "tax_rules_version", "month_index", "gross_monthly_salary", "net_salary", + "idempotency_key", "request_hash", "input_snapshot_json", "policy_snapshot_json", + "result_snapshot_json", "created_at"); + + assertUniqueIndex("salary_calculation_history", "student_user_id", "idempotency_key"); + assertForeignKey("salary_calculation_history", "student_user_id", "users", "id"); + assertForeignKey("salary_calculation_history", "city_code", "salary_city_policy", "city_code"); + assertIndex("salary_calculation_history", "student_user_id", "created_at", "id"); + } + + private void assertTableAndColumns(String table, String... columns) throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualTable = findTable(metadata, table); + if (actualTable == null) { + throw new AssertionError("Missing table " + table); + } + List actualColumns = new ArrayList<>(); + try (ResultSet resultSet = metadata.getColumns(null, null, actualTable, null)) { + while (resultSet.next()) { + actualColumns.add(resultSet.getString("COLUMN_NAME").toLowerCase()); + } + } + for (String column : columns) { + assertTrue(actualColumns.contains(column.toLowerCase()), + "Missing column " + table + "." + column + "; actual=" + actualColumns); + } + } + } + + private void assertUniqueIndex(String table, String... columns) throws SQLException { + assertTrue(hasIndex(table, true, columns), + "Missing unique index on " + table + "(" + String.join(", ", columns) + ")"); + } + + private void assertIndex(String table, String... columns) throws SQLException { + assertTrue(hasIndex(table, false, columns), + "Missing index on " + table + "(" + String.join(", ", columns) + ")"); + } + + private boolean hasIndex(String table, boolean unique, String... expectedColumns) + throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualTable = findTable(metadata, table); + if (actualTable == null) { + return false; + } + Map> indexes = new LinkedHashMap<>(); + try (ResultSet resultSet = metadata.getIndexInfo(null, null, actualTable, false, false)) { + while (resultSet.next()) { + String indexName = resultSet.getString("INDEX_NAME"); + if (indexName == null || resultSet.getBoolean("NON_UNIQUE") == unique) { + continue; + } + indexes.computeIfAbsent(indexName, ignored -> new TreeMap<>()) + .put(resultSet.getShort("ORDINAL_POSITION"), + resultSet.getString("COLUMN_NAME").toLowerCase()); + } + } + List expected = new ArrayList<>(); + for (String column : expectedColumns) { + expected.add(column.toLowerCase()); + } + return indexes.values().stream() + .map(TreeMap::values) + .map(values -> new ArrayList<>(values)) + .anyMatch(expected::equals); + } + } + + private void assertForeignKey(String childTable, String childColumn, + String parentTable, String parentColumn) throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualChildTable = findTable(metadata, childTable); + String actualParentTable = findTable(metadata, parentTable); + if (actualChildTable == null || actualParentTable == null) { + throw new AssertionError("Missing table for foreign key " + childTable + "." + childColumn); + } + try (ResultSet resultSet = metadata.getImportedKeys(null, null, actualChildTable)) { + while (resultSet.next()) { + if (actualParentTable.equalsIgnoreCase(resultSet.getString("PKTABLE_NAME")) + && childColumn.equalsIgnoreCase(resultSet.getString("FKCOLUMN_NAME")) + && parentColumn.equalsIgnoreCase(resultSet.getString("PKCOLUMN_NAME"))) { + return; + } + } + } + } + throw new AssertionError("Missing foreign key " + childTable + "." + childColumn + + " -> " + parentTable + "." + parentColumn); + } + + private String findTable(DatabaseMetaData metadata, String expected) throws SQLException { + try (ResultSet resultSet = metadata.getTables(null, null, null, new String[]{"TABLE"})) { + while (resultSet.next()) { + if (expected.equalsIgnoreCase(resultSet.getString("TABLE_NAME"))) { + return resultSet.getString("TABLE_NAME"); + } + } + } + return null; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryRepositoryIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryRepositoryIntegrationTest.java new file mode 100644 index 0000000..f29128d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryRepositoryIntegrationTest.java @@ -0,0 +1,132 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_salary_repository;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@ActiveProfiles("integration-test") +@Transactional +class SalaryRepositoryIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(18100); + + @Autowired private SalaryCityPolicyRepository cityPolicyRepository; + @Autowired private SalaryCalculationHistoryRepository historyRepository; + @Autowired private UserRepository userRepository; + + @MockBean private RedisTemplate redisTemplate; + + @BeforeEach + void clearData() { + historyRepository.deleteAll(); + cityPolicyRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + void persistsCityPolicyAndUserScopedHistory() { + SalaryCityPolicy beijing = cityPolicyRepository.save(cityPolicy("110000", "北京", "北京市")); + cityPolicyRepository.save(cityPolicy("120000", "天津", "天津市")); + Long studentId = createUser(User.Role.STUDENT, "薪资学生"); + Long otherId = createUser(User.Role.STUDENT, "其他学生"); + + SalaryCalculationHistory first = historyRepository.save(history( + studentId, beijing.getCityCode(), "北京", "key-1", new BigDecimal("10000.00"))); + SalaryCalculationHistory second = historyRepository.save(history( + studentId, beijing.getCityCode(), "北京", "key-2", new BigDecimal("12000.00"))); + + List cities = + cityPolicyRepository.findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc(); + assertThat(cities).extracting(SalaryCityPolicy::getCityCode) + .containsExactly("110000", "120000"); + + Page page = historyRepository + .findByStudentUserIdOrderByCreatedAtDescIdDesc(studentId, PageRequest.of(0, 20)); + assertThat(page.getTotalElements()).isEqualTo(2); + assertThat(page.getContent()).extracting(SalaryCalculationHistory::getId) + .containsExactly(second.getId(), first.getId()); + + assertThat(historyRepository.findByIdAndStudentUserId(first.getId(), studentId)).isPresent(); + assertThat(historyRepository.findByIdAndStudentUserId(first.getId(), otherId)).isEmpty(); + assertThat(historyRepository.findByStudentUserIdAndIdempotencyKey(studentId, "key-1")) + .isPresent(); + } + + private SalaryCityPolicy cityPolicy(String cityCode, String provinceName, String cityName) { + return SalaryCityPolicy.builder() + .cityCode(cityCode) + .provinceCode(cityCode.substring(0, 2) + "0000") + .provinceName(provinceName) + .cityName(cityName) + .dataLevel(SalaryDataLevel.CITY) + .policyVersion("2026-09-20-v1") + .policyEffectiveDate(LocalDate.of(2026, 7, 1)) + .socialSecurityBaseMin(new BigDecimal("6821.00")) + .socialSecurityBaseMax(new BigDecimal("35811.00")) + .housingFundBaseMin(new BigDecimal("2420.00")) + .housingFundBaseMax(new BigDecimal("35811.00")) + .pensionRate(new BigDecimal("0.080000")) + .medicalRate(new BigDecimal("0.020000")) + .unemploymentRate(new BigDecimal("0.002000")) + .housingFundRate(new BigDecimal("0.120000")) + .seriousIllnessMonthlyFee(new BigDecimal("3.00")) + .dataLevel(SalaryDataLevel.CITY) + .enabled(true) + .build(); + } + + private SalaryCalculationHistory history( + Long studentId, String cityCode, String cityName, String idempotencyKey, BigDecimal gross) { + return SalaryCalculationHistory.builder() + .studentUserId(studentId) + .cityCode(cityCode) + .cityName(cityName) + .dataLevel(SalaryDataLevel.CITY) + .policyVersion("2026-09-20-v1") + .taxRulesVersion("CN_IIT_2019_V1") + .monthIndex(1) + .grossMonthlySalary(gross) + .netSalary(gross.subtract(new BigDecimal("500.00"))) + .idempotencyKey(idempotencyKey) + .requestHash("a".repeat(64)) + .inputSnapshotJson("{\"grossMonthlySalary\":10000.00}") + .policySnapshotJson("{\"cityCode\":\"110000\"}") + .resultSnapshotJson("{\"netSalary\":9500.00}") + .createdAt(LocalDateTime.now()) + .build(); + } + + private Long createUser(User.Role role, String nickname) { + User user = userRepository.save(User.builder() + .phone("176" + String.format("%08d", PHONE_SEQ.incrementAndGet())) + .nickname(nickname) + .role(role) + .roles(Set.of(role)) + .active(true) + .verified(true) + .build()); + return user.getId(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudAuthorizationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudAuthorizationIntegrationTest.java new file mode 100644 index 0000000..ba9225c --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudAuthorizationIntegrationTest.java @@ -0,0 +1,239 @@ +package com.mydreamjob.skill; + +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.skill.dto.SkillCloudItem; +import com.mydreamjob.skill.dto.SkillCloudNode; +import com.mydreamjob.skill.dto.SkillCloudResponse; +import com.mydreamjob.skill.dto.SkillLearningPathItem; +import com.mydreamjob.skill.dto.SkillLearningPathResponse; +import com.mydreamjob.skill.dto.SkillMatchResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:skill_controller_auth;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class SkillCloudAuthorizationIntegrationTest { + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.AuthSessionService integrationSessions; + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.UserRepository integrationUsers; + @org.springframework.beans.factory.annotation.Autowired private org.springframework.jdbc.core.JdbcTemplate integrationJdbc; + @org.junit.jupiter.api.AfterEach void cleanupIntegrationSessions() { + com.mydreamjob.integration.LxqTestSessionIssuer.cleanup(integrationJdbc); + } + + + private static final String WORD_CLOUD_PATH = "/skills/word-cloud"; + private static final String MATCH_PATH = "/skills/match"; + private static final String LEARNING_PATH = "/skills/learning-path"; + private static final List PATHS = List.of(WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH); + private static final AtomicLong PHONE = new AtomicLong(920_000_000L); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Autowired + private UserRepository userRepository; + + @MockBean + private SkillCloudQueryService skillCloudQueryService; + + @MockBean + private SkillMatchService skillMatchService; + + @MockBean + private SkillLearningPathService skillLearningPathService; + + @MockBean + private RedisTemplate redisTemplate; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations operations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(operations); + when(redisTemplate.hasKey(any())).thenReturn(false); + + when(skillCloudQueryService.query(1L, 1L, null)).thenReturn(cloudResponse()); + when(skillMatchService.match(anyLong(), eq(1L), eq(1L), isNull())).thenReturn(matchResponse()); + when(skillLearningPathService.generate(anyLong(), eq(1L), eq(1L), isNull())) + .thenReturn(learningPathResponse()); + } + + @AfterEach + void tearDown() { + userRepository.deleteAll(); + } + + @Test + void databaseRoleSetsControlAllSkillEndpoints() throws Exception { + Long student = createUser(Set.of(User.Role.STUDENT)); + Long company = createUser(Set.of(User.Role.COMPANY)); + Long admin = createUser(Set.of(User.Role.ADMIN)); + Long studentCompany = createUser(Set.of(User.Role.STUDENT, User.Role.COMPANY)); + Long companyAdmin = createUser(Set.of(User.Role.COMPANY, User.Role.ADMIN)); + + for (String path : PATHS) { + assertStatus(path, student, 200); + assertStatus(path, company, 403); + assertStatus(path, admin, 403); + assertStatus(path, studentCompany, 200); + assertStatus(path, companyAdmin, 403); + } + } + + @Test + void anonymousAndInvalidJwtUseSecurityEntryPointContract() throws Exception { + for (String path : PATHS) { + assertUnauthorized(path, null); + assertUnauthorized(path, "Bearer invalid-token"); + } + } + + @Test + void identityQueryParametersCannotOverrideAuthenticatedSubject() throws Exception { + Long student = createUser(Set.of(User.Role.STUDENT)); + Long company = createUser(Set.of(User.Role.COMPANY)); + + for (String path : PATHS) { + mockMvc.perform(get(path) + .param("industryId", "1") + .param("occupationId", "1") + .param("userId", company.toString()) + .param("studentId", company.toString()) + .header("Authorization", bearerToken(student))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + verify(skillCloudQueryService).query(1L, 1L, null); + verify(skillMatchService).match(student, 1L, 1L, null); + verify(skillLearningPathService).generate(student, 1L, 1L, null); + } + + private void assertUnauthorized(String path, String authorization) throws Exception { + var request = get(path) + .param("industryId", "1") + .param("occupationId", "1"); + if (authorization != null) { + request.header("Authorization", authorization); + } + mockMvc.perform(request) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)) + .andExpect(jsonPath("$.message").value("not authenticated")) + .andExpect(jsonPath("$.timestamp").value("")) + .andExpect(jsonPath("$.requestId").doesNotExist()) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + private void assertStatus(String path, Long userId, int expectedStatus) throws Exception { + var request = get(path) + .param("industryId", "1") + .param("occupationId", "1") + .header("Authorization", bearerToken(userId)); + if (expectedStatus == 200) { + mockMvc.perform(request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } else { + mockMvc.perform(request) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + } + + private Long createUser(Set roles) { + User user = User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .role(roles.iterator().next()) + .roles(Set.copyOf(roles)) + .active(true) + .verified(true) + .build(); + return userRepository.save(user).getId(); + } + + private String bearerToken(Long userId) { + return "Bearer " + com.mydreamjob.integration.LxqTestSessionIssuer.issue(userId, "STUDENT", integrationSessions, integrationUsers, integrationJdbc); + } + + private SkillCloudResponse cloudResponse() { + return new SkillCloudResponse( + new SkillCloudNode(1L, "INTERNET", "互联网"), + new SkillCloudNode(1L, "FRONTEND_DEVELOPER", "前端开发工程师"), + null, + 1, + List.of(skill()) + ); + } + + private SkillMatchResponse matchResponse() { + return new SkillMatchResponse(100, 1, 1, List.of(skill()), List.of()); + } + + private SkillLearningPathResponse learningPathResponse() { + return new SkillLearningPathResponse( + 1, + List.of(new SkillLearningPathItem( + 1, + 3L, + "JavaScript", + 101L, + "编程语言", + "先掌握 JavaScript 的基础语法和常见数据结构,再完成至少一个编码练习。" + )) + ); + } + + private SkillCloudItem skill() { + return new SkillCloudItem( + 1, + 3L, + "JAVASCRIPT", + "JavaScript", + "JavaScript", + "ESCO", + "http://data.europa.eu/esco/skill/javascript", + 101L, + "编程语言", + new BigDecimal("4.38"), + "15-1254.00", + "2.B.3.e", + false + ); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudControllerTest.java new file mode 100644 index 0000000..e2dc9ae --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudControllerTest.java @@ -0,0 +1,433 @@ +package com.mydreamjob.skill; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.skill.dto.SkillCloudItem; +import com.mydreamjob.skill.dto.SkillCloudNode; +import com.mydreamjob.skill.dto.SkillCloudResponse; +import com.mydreamjob.skill.dto.SkillLearningPathItem; +import com.mydreamjob.skill.dto.SkillLearningPathResponse; +import com.mydreamjob.skill.dto.SkillMatchResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +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.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:skill_controller_api;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class SkillCloudControllerTest { + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.AuthSessionService integrationSessions; + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.UserRepository integrationUsers; + @org.springframework.beans.factory.annotation.Autowired private org.springframework.jdbc.core.JdbcTemplate integrationJdbc; + @org.junit.jupiter.api.AfterEach void cleanupIntegrationSessions() { + com.mydreamjob.integration.LxqTestSessionIssuer.cleanup(integrationJdbc); + } + + + private static final String WORD_CLOUD_PATH = "/skills/word-cloud"; + private static final String MATCH_PATH = "/skills/match"; + private static final String LEARNING_PATH = "/skills/learning-path"; + private static final AtomicLong PHONE = new AtomicLong(910_000_000L); + + private Long studentId; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + + @Autowired + private UserRepository userRepository; + + @MockBean + private SkillCloudQueryService skillCloudQueryService; + + @MockBean + private SkillMatchService skillMatchService; + + @MockBean + private SkillLearningPathService skillLearningPathService; + + @MockBean + private RedisTemplate redisTemplate; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations operations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(operations); + when(redisTemplate.hasKey(any())).thenReturn(false); + + studentId = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + + when(skillCloudQueryService.query(1L, 1L, null)).thenReturn(cloudResponse()); + when(skillMatchService.match(studentId, 1L, 1L, null)).thenReturn(matchResponse()); + when(skillLearningPathService.generate(studentId, 1L, 1L, null)).thenReturn(learningPathResponse()); + } + + @AfterEach + void tearDown() { + userRepository.deleteAll(); + } + + @Test + void wordCloudStudentRequestReturnsServicePayload() throws Exception { + mockMvc.perform(get(WORD_CLOUD_PATH) + .param("industryId", "1") + .param("occupationId", "1") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.industry.code").value("INTERNET")) + .andExpect(jsonPath("$.data.occupation.code").value("FRONTEND_DEVELOPER")) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.items[0].skillId").value(3)) + .andExpect(jsonPath("$.data.items[0].nameZh").value("JavaScript")) + .andExpect(jsonPath("$.data.items[0].sourceType").value("ESCO")); + } + + @Test + void matchStudentRequestReturnsServicePayload() throws Exception { + mockMvc.perform(get(MATCH_PATH) + .param("industryId", "1") + .param("occupationId", "1") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.matchScore").value(100)) + .andExpect(jsonPath("$.data.matchedCount").value(1)) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.matchedSkills[0].skillId").value(3)) + .andExpect(jsonPath("$.data.missingSkills").isEmpty()); + } + + @Test + void learningPathStudentRequestReturnsServicePayload() throws Exception { + mockMvc.perform(get(LEARNING_PATH) + .param("industryId", "1") + .param("occupationId", "1") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.items[0].priority").value(1)) + .andExpect(jsonPath("$.data.items[0].skillId").value(3)) + .andExpect(jsonPath("$.data.items[0].suggestion") + .value("先掌握 JavaScript 的基础语法和常见数据结构,再完成至少一个编码练习。")); + } + + @ParameterizedTest + @ValueSource(strings = {WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH}) + void anonymousRequestUsesSecurityEntryPointContract(String path) throws Exception { + var result = mockMvc.perform(get(path) + .param("industryId", "1") + .param("occupationId", "1")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)) + .andExpect(jsonPath("$.message").value("not authenticated")) + .andExpect(jsonPath("$.timestamp").value("")) + .andExpect(jsonPath("$.requestId").doesNotExist()) + .andExpect(jsonPath("$.data").doesNotExist()) + .andReturn(); + + JsonNode body = objectMapper.readTree( + result.getResponse().getContentAsString(StandardCharsets.UTF_8)); + assertFields(body, Set.of("code", "message", "timestamp")); + } + + + @Test + void missingRequiredParametersReturnBadRequest() throws Exception { + mockMvc.perform(get(WORD_CLOUD_PATH) + .param("occupationId", "1") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + mockMvc.perform(get(WORD_CLOUD_PATH) + .param("industryId", "1") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + @Test + void nonPositiveParametersReturnBadRequest() throws Exception { + mockMvc.perform(get(WORD_CLOUD_PATH) + .param("industryId", "0") + .param("occupationId", "1") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + mockMvc.perform(get(MATCH_PATH) + .param("industryId", "1") + .param("occupationId", "-1") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + mockMvc.perform(get(LEARNING_PATH) + .param("industryId", "1") + .param("occupationId", "1") + .param("categoryId", "0") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + @Test + void identityComesOnlyFromAuthenticatedPrincipal() throws Exception { + mockMvc.perform(get(MATCH_PATH) + .param("industryId", "1") + .param("occupationId", "1") + .param("userId", "999") + .param("studentId", "888") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.matchScore").value(100)); + + verify(skillMatchService).match(eq(studentId), eq(1L), eq(1L), isNull()); + } + + @ParameterizedTest + @ValueSource(strings = {WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH}) + void successfulResponsesDoNotExposePrivateProfileFields(String path) throws Exception { + String body = mockMvc.perform(get(path) + .param("industryId", "1") + .param("occupationId", "1") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(body) + .doesNotContain("skills_json") + .doesNotContain("phone") + .doesNotContain("email") + .doesNotContain("resume"); + } + + @Test + void skillOperationIdsAreExactAndGloballyUnique() throws Exception { + String json = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + JsonNode paths = objectMapper.readTree(json).path("paths"); + + Map> locationsByOperationId = new HashMap<>(); + paths.fields().forEachRemaining(path -> path.getValue().fields().forEachRemaining(operation -> { + String operationId = operation.getValue().path("operationId").asText(); + if (!operationId.isBlank()) { + locationsByOperationId + .computeIfAbsent(operationId, ignored -> new ArrayList<>()) + .add(operation.getKey().toUpperCase(Locale.ROOT) + " " + path.getKey()); + } + })); + + Map expectedOperationIds = Map.of( + WORD_CLOUD_PATH, "getWordCloud", + MATCH_PATH, "getSkillMatch", + LEARNING_PATH, "getLearningPath"); + expectedOperationIds.forEach((path, operationId) -> { + assertThat(paths.path(path).path("get").path("operationId").asText()).isEqualTo(operationId); + assertThat(locationsByOperationId.getOrDefault(operationId, List.of())) + .containsExactly("GET " + path); + }); + } + + @Test + void openApiDocumentsStudentProtectedSkillEndpoints() throws Exception { + String json = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + JsonNode root = objectMapper.readTree(json); + JsonNode paths = root.path("paths"); + + for (String path : List.of(WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH)) { + JsonNode get = paths.path(path).path("get"); + assertThat(get.isMissingNode()).isFalse(); + assertThat(get.path("security").toString()).contains("bearerAuth"); + assertThat(get.path("responses").has("200")).isTrue(); + assertThat(get.path("responses").has("400")).isTrue(); + assertThat(get.path("responses").has("401")).isTrue(); + assertThat(get.path("responses").has("403")).isTrue(); + + JsonNode parameters = get.path("parameters"); + assertThat(parameter(parameters, "industryId").path("required").asBoolean()).isTrue(); + assertThat(parameter(parameters, "occupationId").path("required").asBoolean()).isTrue(); + assertThat(parameter(parameters, "categoryId").path("required").asBoolean()).isFalse(); + } + } + + @Test + void openApiSuccessExamplesMatchDtoContract() throws Exception { + String json = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + JsonNode paths = objectMapper.readTree(json).path("paths"); + + JsonNode wordCloud = successExample(paths.path(WORD_CLOUD_PATH).path("get")); + JsonNode wordCloudData = wordCloud.path("data"); + assertFields(wordCloudData, Set.of("industry", "occupation", "category", "total", "items")); + assertThat(wordCloudData.path("total").asInt()).isEqualTo(wordCloudData.path("items").size()); + assertSkillItemContract(wordCloudData.path("items").get(0)); + + JsonNode match = successExample(paths.path(MATCH_PATH).path("get")); + JsonNode matchData = match.path("data"); + assertFields(matchData, Set.of("matchScore", "matchedCount", "totalCount", "matchedSkills", "missingSkills")); + int matchedCount = matchData.path("matchedCount").asInt(); + int totalCount = matchData.path("totalCount").asInt(); + assertThat(matchData.path("matchedSkills").size()).isEqualTo(matchedCount); + assertThat(matchData.path("missingSkills").size()).isEqualTo(totalCount - matchedCount); + int expectedScore = totalCount == 0 ? 0 : (int) Math.round(matchedCount * 100.0 / totalCount); + assertThat(matchData.path("matchScore").asInt()).isEqualTo(expectedScore); + assertSkillItemContract(matchData.path("matchedSkills").get(0)); + assertSkillItemContract(matchData.path("missingSkills").get(0)); + + JsonNode learningPath = successExample(paths.path(LEARNING_PATH).path("get")); + JsonNode learningData = learningPath.path("data"); + assertFields(learningData, Set.of("total", "items")); + assertThat(learningData.path("total").asInt()).isEqualTo(learningData.path("items").size()); + assertFields(learningData.path("items").get(0), + Set.of("priority", "skillId", "skillName", "categoryId", "categoryName", "suggestion")); + } + + private JsonNode successExample(JsonNode get) throws Exception { + JsonNode value = get.path("responses").path("200").path("content") + .path("application/json").path("examples").path("success").path("value"); + assertThat(value.isMissingNode()).isFalse(); + return value.isTextual() ? objectMapper.readTree(value.asText()) : value; + } + + private void assertSkillItemContract(JsonNode item) { + assertThat(item.isMissingNode()).isFalse(); + assertFields(item, Set.of( + "rank", "skillId", "skillCode", "nameZh", "nameEn", "sourceType", "sourceCode", + "categoryId", "categoryName", "heat", "heatSourceCode", "heatElementId", "heatProxy")); + } + + private void assertFields(JsonNode object, Set expectedFields) { + Set actualFields = new HashSet<>(); + object.fieldNames().forEachRemaining(actualFields::add); + assertThat(actualFields).containsExactlyInAnyOrderElementsOf(expectedFields); + } + private JsonNode parameter(JsonNode parameters, String name) { + for (JsonNode parameter : parameters) { + if (name.equals(parameter.path("name").asText())) { + return parameter; + } + } + return objectMapper.missingNode(); + } + + + private String studentToken() { + return token(studentId, "STUDENT"); + } + + private String token(long userId, String role) { + return "Bearer " + com.mydreamjob.integration.LxqTestSessionIssuer.issue(userId, role, integrationSessions, integrationUsers, integrationJdbc); + } + + private SkillCloudResponse cloudResponse() { + SkillCloudItem skill = skill(); + return new SkillCloudResponse( + new SkillCloudNode(1L, "INTERNET", "互联网"), + new SkillCloudNode(1L, "FRONTEND_DEVELOPER", "前端开发工程师"), + null, + 1, + List.of(skill) + ); + } + + private SkillMatchResponse matchResponse() { + return new SkillMatchResponse(100, 1, 1, List.of(skill()), List.of()); + } + + private SkillLearningPathResponse learningPathResponse() { + return new SkillLearningPathResponse( + 1, + List.of(new SkillLearningPathItem( + 1, + 3L, + "JavaScript", + 101L, + "编程语言", + "先掌握 JavaScript 的基础语法和常见数据结构,再完成至少一个编码练习。" + )) + ); + } + + private SkillCloudItem skill() { + return new SkillCloudItem( + 1, + 3L, + "JAVASCRIPT", + "JavaScript", + "JavaScript", + "ESCO", + "http://data.europa.eu/esco/skill/javascript", + 101L, + "编程语言", + new BigDecimal("4.38"), + "15-1254.00", + "2.B.3.e", + false + ); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudEndToEndIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudEndToEndIntegrationTest.java new file mode 100644 index 0000000..abeb8a5 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudEndToEndIntegrationTest.java @@ -0,0 +1,455 @@ +package com.mydreamjob.skill; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.profile.StudentProfile; +import com.mydreamjob.profile.StudentProfileRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.data.redis.core.ValueOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * T9: real HTTP -> Security -> Controller -> Service -> Repository -> Flyway/H2 regression. + * + *

The test deliberately uses the real V36 data and real persisted student profile. It does + * not mock the skill services, so it can catch inconsistent TOP10 derivation, accidental writes, + * and response-layer privacy leaks.

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:skill_cloud_t9_e2e;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "spring.jpa.show-sql=false" +}) +@org.springframework.transaction.annotation.Transactional +class SkillCloudEndToEndIntegrationTest { + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.AuthSessionService integrationSessions; + @org.springframework.beans.factory.annotation.Autowired private com.mydreamjob.auth.UserRepository integrationUsers; + @org.springframework.beans.factory.annotation.Autowired private org.springframework.jdbc.core.JdbcTemplate integrationJdbc; + @org.junit.jupiter.api.AfterEach void cleanupIntegrationSessions() { + com.mydreamjob.integration.LxqTestSessionIssuer.cleanup(integrationJdbc); + } + + + private static final String WORD_CLOUD_PATH = "/skills/word-cloud"; + private static final String MATCH_PATH = "/skills/match"; + private static final String LEARNING_PATH = "/skills/learning-path"; + + private static final long BACKEND_INDUSTRY_ID = 1L; + private static final long BACKEND_OCCUPATION_ID = 2L; + private static final long THREE_SKILL_INDUSTRY_ID = 3L; + private static final long THREE_SKILL_OCCUPATION_ID = 9L; + private static final long THREE_SKILL_CATEGORY_ID = 903L; + private static final long EMPTY_CATEGORY_ID = 999_001L; + + private static final AtomicLong PHONE = new AtomicLong(940_000_000L); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StudentProfileRepository studentProfileRepository; + + @MockBean + private RedisTemplate redisTemplate; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUpRedisMock() { + ValueOperations operations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(operations); + when(redisTemplate.hasKey(any())).thenReturn(false); + } + + @Test + void threeEndpointsShareTop10AndDoNotWriteForBackendDeveloper() throws Exception { + StudentFixture student = createStudent("[\"Django\",\"Git\",\"Java\"]"); + Map countsBefore = taxonomyAndProfileCounts(); + + JsonNode cloud = data(getOkBody( + WORD_CLOUD_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null)); + List allSkills = nodes(cloud.path("items")); + assertThat(cloud.path("total").asInt()).isEqualTo(16); + assertThat(allSkills).hasSize(16); + assertRanksAreContinuous(allSkills); + assertHeatOrder(allSkills); + + List expectedTop10 = allSkills.subList(0, Math.min(10, allSkills.size())); + + JsonNode match = data(getOkBody( + MATCH_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null)); + assertMatchReusesTop10(match, expectedTop10); + + JsonNode learningPath = data(getOkBody( + LEARNING_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null)); + assertLearningPathFollowsMissingSkills(learningPath, match.path("missingSkills")); + + JsonNode cloudAgain = data(getOkBody( + WORD_CLOUD_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null)); + JsonNode matchAgain = data(getOkBody( + MATCH_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null)); + JsonNode learningAgain = data(getOkBody( + LEARNING_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null)); + + assertThat(cloudAgain).isEqualTo(cloud); + assertThat(matchAgain).isEqualTo(match); + assertThat(learningAgain).isEqualTo(learningPath); + assertThat(taxonomyAndProfileCounts()).isEqualTo(countsBefore); + } + + @Test + void matchScoreUsesActualTopSizeAndRoundsToOneThird() throws Exception { + StudentFixture student = createStudent("[\"Articulate Storyline\"]"); + + JsonNode cloud = data(getOkBody( + WORD_CLOUD_PATH, + THREE_SKILL_INDUSTRY_ID, + THREE_SKILL_OCCUPATION_ID, + THREE_SKILL_CATEGORY_ID, + student.authorization(), + null)); + List skills = nodes(cloud.path("items")); + assertThat(cloud.path("total").asInt()).isEqualTo(3); + assertThat(skills).hasSize(3); + assertHeatOrder(skills); + JsonNode lastHeat = skills.get(skills.size() - 1).path("heat"); + assertThat(lastHeat.isMissingNode() || lastHeat.isNull()).isTrue(); + + JsonNode match = data(getOkBody( + MATCH_PATH, + THREE_SKILL_INDUSTRY_ID, + THREE_SKILL_OCCUPATION_ID, + THREE_SKILL_CATEGORY_ID, + student.authorization(), + null)); + assertThat(match.path("totalCount").asInt()).isEqualTo(3); + assertThat(match.path("matchedCount").asInt()).isEqualTo(1); + assertThat(match.path("matchScore").asInt()).isEqualTo(33); + assertThat(match.path("matchedSkills")).hasSize(1); + assertThat(match.path("missingSkills")).hasSize(2); + assertThat(ids(match.path("matchedSkills"))) + .containsExactly(skills.get(0).path("skillId").asLong()); + + JsonNode learningPath = data(getOkBody( + LEARNING_PATH, + THREE_SKILL_INDUSTRY_ID, + THREE_SKILL_OCCUPATION_ID, + THREE_SKILL_CATEGORY_ID, + student.authorization(), + null)); + assertLearningPathFollowsMissingSkills(learningPath, match.path("missingSkills")); + } + + @Test + void emptyCategoryReturnsEmptyResponsesAtPersistenceBoundary() throws Exception { + StudentFixture student = createStudent("[\"Java\"]"); + jdbcTemplate.update( + "INSERT INTO skill_categories " + + "(id, occupation_id, code, name_zh, suggestion_template_code, sort_order, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + EMPTY_CATEGORY_ID, + BACKEND_OCCUPATION_ID, + "T9_EMPTY_CATEGORY", + "T9 空分类", + "GENERAL", + 99); + + JsonNode cloud = data(getOkBody( + WORD_CLOUD_PATH, + BACKEND_INDUSTRY_ID, + BACKEND_OCCUPATION_ID, + EMPTY_CATEGORY_ID, + student.authorization(), + null)); + assertThat(cloud.path("total").asInt()).isZero(); + assertThat(cloud.path("items")).isEmpty(); + + JsonNode match = data(getOkBody( + MATCH_PATH, + BACKEND_INDUSTRY_ID, + BACKEND_OCCUPATION_ID, + EMPTY_CATEGORY_ID, + student.authorization(), + null)); + assertThat(match.path("matchScore").asInt()).isZero(); + assertThat(match.path("matchedCount").asInt()).isZero(); + assertThat(match.path("totalCount").asInt()).isZero(); + assertThat(match.path("matchedSkills")).isEmpty(); + assertThat(match.path("missingSkills")).isEmpty(); + + JsonNode learningPath = data(getOkBody( + LEARNING_PATH, + BACKEND_INDUSTRY_ID, + BACKEND_OCCUPATION_ID, + EMPTY_CATEGORY_ID, + student.authorization(), + null)); + assertThat(learningPath.path("total").asInt()).isZero(); + assertThat(learningPath.path("items")).isEmpty(); + } + + @Test + void identityQueryParametersCannotOverrideAuthenticatedStudent() throws Exception { + StudentFixture student = createStudent("[\"Java\"]"); + User company = createUser(User.Role.COMPANY); + + for (String path : List.of(WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH)) { + JsonNode authenticated = data(getOkBody( + path, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null)); + JsonNode withOverrides = data(getOkBody( + path, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), company.getId())); + + assertThat(withOverrides).isEqualTo(authenticated); + } + } + + @Test + void responsesDoNotLeakPrivateProfileDataOrInternalErrors() throws Exception { + StudentFixture student = createStudent("[\"Java\"]"); + + for (String path : List.of(WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH)) { + String body = getOkBody( + path, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null); + assertNoPrivateData(body, student); + } + + String errorBody = mockMvc.perform(get(WORD_CLOUD_PATH) + .param("industryId", String.valueOf(BACKEND_INDUSTRY_ID)) + .param("occupationId", String.valueOf(BACKEND_OCCUPATION_ID)) + .param("categoryId", "999999") + .header("Authorization", student.authorization())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").isNumber()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + + assertThat(errorBody.toLowerCase(Locale.ROOT)) + .doesNotContain("exception") + .doesNotContain("org.springframework") + .doesNotContain("java.sql") + .doesNotContain("stacktrace") + .doesNotContain("\tat "); + } + + private void assertMatchReusesTop10(JsonNode match, List expectedTop10) { + List expectedIds = ids(expectedTop10); + List matchedIds = ids(match.path("matchedSkills")); + List missingIds = ids(match.path("missingSkills")); + + assertThat(match.path("totalCount").asInt()).isEqualTo(expectedTop10.size()); + assertThat(match.path("matchedCount").asInt()).isEqualTo(matchedIds.size()); + assertThat(matchedIds).allMatch(expectedIds::contains); + assertThat(expectedIds).containsAll(matchedIds); + + List expectedMatched = expectedIds.stream().filter(matchedIds::contains).toList(); + List expectedMissing = expectedIds.stream().filter(id -> !matchedIds.contains(id)).toList(); + assertThat(matchedIds).containsExactlyElementsOf(expectedMatched); + assertThat(missingIds).containsExactlyElementsOf(expectedMissing); + + List union = new ArrayList<>(matchedIds); + union.addAll(missingIds); + assertThat(union).containsExactlyInAnyOrderElementsOf(expectedIds); + int expectedScore = expectedTop10.isEmpty() + ? 0 + : (int) Math.round(matchedIds.size() * 100.0 / expectedTop10.size()); + assertThat(match.path("matchScore").asInt()).isEqualTo(expectedScore); + } + + private void assertLearningPathFollowsMissingSkills(JsonNode learningPath, JsonNode missingSkills) { + assertThat(learningPath.path("total").asInt()).isEqualTo(missingSkills.size()); + assertThat(learningPath.path("items")).hasSize(missingSkills.size()); + for (int index = 0; index < missingSkills.size(); index++) { + JsonNode item = learningPath.path("items").get(index); + JsonNode missing = missingSkills.get(index); + assertThat(item.path("priority").asInt()).isEqualTo(index + 1); + assertThat(item.path("skillId").asLong()).isEqualTo(missing.path("skillId").asLong()); + assertThat(item.path("skillName").asText()).isEqualTo(missing.path("nameZh").asText()); + assertThat(item.path("suggestion").asText()).isNotBlank(); + } + } + + private void assertRanksAreContinuous(List items) { + for (int index = 0; index < items.size(); index++) { + assertThat(items.get(index).path("rank").asInt()).isEqualTo(index + 1); + } + } + + private void assertHeatOrder(List items) { + boolean seenNullHeat = false; + BigDecimal previousHeat = null; + for (JsonNode item : items) { + if (item.path("heat").isNull()) { + seenNullHeat = true; + continue; + } + assertThat(seenNullHeat).isFalse(); + BigDecimal currentHeat = item.path("heat").decimalValue(); + if (previousHeat != null) { + assertThat(currentHeat).isLessThanOrEqualTo(previousHeat); + } + previousHeat = currentHeat; + } + } + + private void assertNoPrivateData(String body, StudentFixture student) { + assertThat(body).doesNotContain("skills_json"); + assertThat(body).doesNotContain(student.phone()); + assertThat(body).doesNotContain(student.email()); + assertThat(body).doesNotContain(student.resumeUrl()); + assertThat(body).doesNotContain(student.resumeOriginalName()); + assertThat(body).doesNotContain(student.accessToken()); + } + + private String getOkBody(String path, + long industryId, + long occupationId, + Long categoryId, + String authorization, + Long overrideIdentity) throws Exception { + var request = get(path) + .param("industryId", String.valueOf(industryId)) + .param("occupationId", String.valueOf(occupationId)) + .header("Authorization", authorization); + if (categoryId != null) { + request.param("categoryId", String.valueOf(categoryId)); + } + if (overrideIdentity != null) { + request.param("userId", String.valueOf(overrideIdentity)); + request.param("studentId", String.valueOf(overrideIdentity)); + } + + return mockMvc.perform(request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + } + + private JsonNode data(String body) throws Exception { + return objectMapper.readTree(body).path("data"); + } + + private List nodes(JsonNode array) { + List nodes = new ArrayList<>(); + array.forEach(nodes::add); + return nodes; + } + + private List ids(JsonNode array) { + return ids(nodes(array)); + } + + private List ids(List items) { + return items.stream().map(node -> node.path("skillId").asLong()).toList(); + } + + private StudentFixture createStudent(String skillsJson) { + long phoneNumber = PHONE.getAndIncrement(); + String phone = String.valueOf(phoneNumber); + String email = "private.student." + phoneNumber + "@example.com"; + String resumeUrl = "https://private.example/" + phoneNumber + "-resume.pdf"; + String resumeOriginalName = "private-" + phoneNumber + "-resume.pdf"; + + User user = userRepository.saveAndFlush(User.builder() + .phone(phone) + .email(email) + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()); + studentProfileRepository.saveAndFlush(StudentProfile.builder() + .userId(user.getId()) + .realName("sensitive-real-name") + .bio("raw-resume-sentinel") + .resumeUrl(resumeUrl) + .resumeOriginalName(resumeOriginalName) + .skillsJson(skillsJson) + .build()); + + String accessToken = com.mydreamjob.integration.LxqTestSessionIssuer.issue(user.getId(), "STUDENT", integrationSessions, integrationUsers, integrationJdbc); + return new StudentFixture(user.getId(), phone, email, resumeUrl, resumeOriginalName, accessToken); + } + + private User createUser(User.Role role) { + long phoneNumber = PHONE.getAndIncrement(); + return userRepository.saveAndFlush(User.builder() + .phone(String.valueOf(phoneNumber)) + .role(role) + .roles(Set.of(role)) + .active(true) + .verified(true) + .build()); + } + + private Map taxonomyAndProfileCounts() { + Map counts = new LinkedHashMap<>(); + for (String table : List.of( + "skill_source_versions", + "skill_industries", + "skill_occupations", + "skill_categories", + "skills", + "skill_aliases", + "occupation_skills", + "student_profiles")) { + Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table, Long.class); + counts.put(table, count); + } + return counts; + } + + private record StudentFixture( + Long userId, + String phone, + String email, + String resumeUrl, + String resumeOriginalName, + String accessToken + ) { + String authorization() { + return "Bearer " + accessToken; + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java new file mode 100644 index 0000000..c207304 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java @@ -0,0 +1,325 @@ +package com.mydreamjob.skill; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.skill.domain.SkillIndustry; +import com.mydreamjob.skill.domain.SkillOccupation; +import com.mydreamjob.skill.dto.SkillCloudItem; +import com.mydreamjob.skill.dto.SkillCloudResponse; +import com.mydreamjob.skill.dto.SkillCloudSkillProjection; +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.hibernate.boot.model.relational.Namespace; +import org.hibernate.boot.model.relational.Sequence; +import org.hibernate.mapping.Table; +import org.hibernate.tool.schema.spi.SchemaFilter; +import org.hibernate.tool.schema.spi.SchemaFilterProvider; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import com.mydreamjob.skill.repository.OccupationSkillRepository; +import com.mydreamjob.skill.repository.SkillCategoryRepository; +import com.mydreamjob.skill.repository.SkillIndustryRepository; +import com.mydreamjob.skill.repository.SkillOccupationRepository; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SpringBootTest +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:integrationdb_skill_query;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "spring.jpa.hibernate.ddl-auto=validate", + "spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=com.mydreamjob.skill.SkillCloudQueryServiceTest$SkillQuerySchemaFilterProvider" +}) +@ActiveProfiles("integration-test") +@Transactional +class SkillCloudQueryServiceTest { + + @Autowired + private SkillCloudQueryService service; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockBean + private RedisTemplate redisTemplate; + + @Test + void returnsAllSeedSkillsForIndustryAndOccupationWithContinuousRanks() { + SkillCloudResponse response = service.query(1L, 1L, null); + + assertThat(response.industry().id()).isEqualTo(1L); + assertThat(response.industry().code()).isEqualTo("INTERNET"); + assertThat(response.industry().name()).isEqualTo("互联网"); + assertThat(response.occupation().id()).isEqualTo(1L); + assertThat(response.occupation().code()).isEqualTo("FRONTEND_DEVELOPER"); + assertThat(response.occupation().name()).isEqualTo("前端开发工程师"); + assertThat(response.category()).isNull(); + assertThat(response.total()).isEqualTo(15); + assertThat(response.items()).hasSize(15); + assertThat(response.items()) + .extracting(SkillCloudItem::rank) + .containsExactlyElementsOf(IntStream.rangeClosed(1, 15).boxed().toList()); + assertThat(response.items()) + .extracting(SkillCloudItem::skillId) + .doesNotHaveDuplicates(); + } + + @Test + void sortsByHeatThenNameAndKeepsNullHeatLast() { + SkillCloudResponse response = service.query(1L, 1L, 104L); + + assertThat(response.category().id()).isEqualTo(104L); + assertThat(response.category().code()).isEqualTo("TOOLS"); + assertThat(response.category().name()).isEqualTo("工程化工具"); + assertThat(response.items()) + .extracting(SkillCloudItem::nameZh) + .containsExactly("Gradle", "Node.js", "Figma"); + assertThat(response.items()) + .extracting(SkillCloudItem::heat) + .containsExactly(new BigDecimal("4.88"), new BigDecimal("4.88"), null); + assertThat(response.items().get(response.items().size() - 1).heat()).isNull(); + + SkillCloudResponse all = service.query(1L, 1L, null); + assertThat(all.items().stream() + .map(SkillCloudItem::heat) + .filter(java.util.Objects::nonNull) + .toList()) + .isSortedAccordingTo(java.util.Comparator.reverseOrder()); + assertThat(all.items().get(all.items().size() - 1).heat()).isNull(); + assertThat(all.items().subList(0, all.items().size() - 1)) + .allSatisfy(item -> assertThat(item.heat()).isNotNull()); + } + + @Test + void comparatorUsesSkillIdWhenEqualKeysArriveInReverseOrder() { + SkillIndustry industry = new SkillIndustry(); + industry.setId(1L); + industry.setCode("INTERNET"); + industry.setNameZh("互联网"); + + SkillOccupation occupation = new SkillOccupation(); + occupation.setId(1L); + occupation.setIndustryId(1L); + occupation.setCode("FRONTEND_DEVELOPER"); + occupation.setNameZh("前端开发工程师"); + + SkillIndustryRepository industryRepository = mock(SkillIndustryRepository.class); + when(industryRepository.findById(1L)).thenReturn(Optional.of(industry)); + + SkillOccupationRepository occupationRepository = mock(SkillOccupationRepository.class); + when(occupationRepository.findById(1L)).thenReturn(Optional.of(occupation)); + + OccupationSkillRepository occupationSkillRepository = mock(OccupationSkillRepository.class); + when(occupationSkillRepository.findSkillProjectionsByOccupationId(1L)) + .thenReturn(List.of( + projection(9002L, "TIE_SKILL_HIGH"), + projection(9001L, "TIE_SKILL_LOW"))); + + SkillCloudQueryService isolatedService = new SkillCloudQueryService( + industryRepository, + occupationRepository, + mock(SkillCategoryRepository.class), + occupationSkillRepository); + + SkillCloudResponse response = isolatedService.query(1L, 1L, null); + + assertThat(response.items()) + .extracting(SkillCloudItem::skillId) + .containsExactly(9001L, 9002L); + } + + private SkillCloudSkillProjection projection(Long skillId, String skillCode) { + return new SkillCloudSkillProjection( + skillId, + skillCode, + "同名技能", + "Tie Skill", + "O*NET", + "TEST|Tie Skill", + 104L, + "工程化工具", + new BigDecimal("4.88"), + "15-1254.00", + "4.A.3.b.1", + true, + 1, + 1 + ); + } + + @Test + void categoryFilterReturnsOnlyTheRequestedCategoryAndOmittedCategoryReturnsMore() { + SkillCloudResponse all = service.query(1L, 1L, null); + SkillCloudResponse filtered = service.query(1L, 1L, 101L); + + assertThat(filtered.total()).isEqualTo(2); + assertThat(filtered.items()) + .extracting(SkillCloudItem::categoryId) + .containsOnly(101L); + assertThat(filtered.items()) + .extracting(SkillCloudItem::skillId) + .containsExactlyInAnyOrder(3L, 4L); + assertThat(all.total()).isGreaterThan(filtered.total()); + } + + @Test + void returnsEmptySuccessForLegalCategoryWithoutSkills() { + jdbcTemplate.update(""" + INSERT INTO skill_categories + (id, occupation_id, code, name_zh, suggestion_template_code, sort_order) + VALUES + (9101, 1, 'EMPTY_CATEGORY', '空分类', 'GENERAL', 99) + """); + + SkillCloudResponse response = service.query(1L, 1L, 9101L); + + assertThat(response.category().id()).isEqualTo(9101L); + assertThat(response.category().name()).isEqualTo("空分类"); + assertThat(response.total()).isZero(); + assertThat(response.items()).isEmpty(); + } + + @Test + void mapsSkillAndHeatProvenanceFieldsFromTheDatabase() { + SkillCloudResponse frameworks = service.query(1L, 1L, 103L); + SkillCloudItem react = frameworks.items().stream() + .filter(item -> item.skillId().equals(26L)) + .findFirst() + .orElseThrow(); + + Map row = jdbcTemplate.queryForMap(""" + SELECT os.importance, os.importance_source_code, os.importance_element_id, + os.importance_is_proxy + FROM occupation_skills os + WHERE os.occupation_id = 1 AND os.skill_id = 26 + """); + + assertThat(react.rank()).isEqualTo(1); + assertThat(react.skillCode()).isEqualTo("REACT"); + assertThat(react.nameZh()).isEqualTo("React"); + assertThat(react.nameEn()).isEqualTo("React"); + assertThat(react.categoryId()).isEqualTo(103L); + assertThat(react.categoryName()).isEqualTo("前端框架"); + assertThat(react.sourceType()).isEqualTo("O*NET"); + assertThat(react.sourceCode()).isEqualTo("15-1254.00|React|2.E.7.h"); + assertThat(react.heat()).isEqualByComparingTo((BigDecimal) row.get("importance")); + assertThat(react.heatSourceCode()).isEqualTo(row.get("importance_source_code")); + assertThat(react.heatElementId()).isEqualTo(row.get("importance_element_id")); + assertThat(react.heatProxy()).isEqualTo(toBoolean(row.get("importance_is_proxy"))); + + SkillCloudResponse languages = service.query(1L, 1L, 101L); + SkillCloudItem javascript = languages.items().stream() + .filter(item -> item.skillId().equals(3L)) + .findFirst() + .orElseThrow(); + + assertThat(javascript.sourceType()).isEqualTo("ESCO"); + assertThat(javascript.sourceCode()) + .isEqualTo("http://data.europa.eu/esco/skill/3cd569a2-4f88-4c1e-9995-8dce8c5e51a7"); + assertThat(javascript.heatSourceCode()).isEqualTo("15-1254.00"); + } + + @Test + void rejectsMissingOrNonPositiveIdentifiersWithBadRequest() { + assertBadRequest(() -> service.query(null, 1L, null), "industryId"); + assertBadRequest(() -> service.query(0L, 1L, null), "industryId"); + assertBadRequest(() -> service.query(-1L, 1L, null), "industryId"); + assertBadRequest(() -> service.query(1L, null, null), "occupationId"); + assertBadRequest(() -> service.query(1L, 0L, null), "occupationId"); + assertBadRequest(() -> service.query(1L, -1L, null), "occupationId"); + assertBadRequest(() -> service.query(1L, 1L, 0L), "categoryId"); + assertBadRequest(() -> service.query(1L, 1L, -1L), "categoryId"); + } + + @Test + void rejectsUnknownOrMismatchedTaxonomyNodesWithBadRequest() { + assertBadRequest(() -> service.query(999L, 1L, null), "industry not found"); + assertBadRequest(() -> service.query(1L, 999L, null), "occupation not found"); + assertBadRequest(() -> service.query(2L, 1L, null), "occupation does not belong"); + assertBadRequest(() -> service.query(1L, 1L, 201L), "category does not belong"); + assertBadRequest(() -> service.query(1L, 1L, 9999L), "category not found"); + } + + private boolean toBoolean(Object value) { + if (value instanceof Boolean booleanValue) { + return booleanValue; + } + return ((Number) value).intValue() != 0; + } + + private void assertBadRequest(ThrowingCallable action, String detailFragment) { + BusinessException exception = catchThrowableOfType(action, BusinessException.class); + assertThat(exception).isNotNull(); + assertThat(exception.getErrorCodeInt()).isEqualTo(ErrorCode.BAD_REQUEST.getCode()); + assertThat(exception.getErrorMessage()).contains(detailFragment); + } + + public static class SkillQuerySchemaFilterProvider implements SchemaFilterProvider { + + private static final Set VALIDATED_TABLES = Set.of( + "skill_source_versions", + "skill_industries", + "skill_occupations", + "skill_categories", + "skills", + "skill_aliases", + "occupation_skills" + ); + + @Override + public SchemaFilter getCreateFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getDropFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getTruncatorFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getMigrateFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getValidateFilter() { + return new SchemaFilter() { + @Override + public boolean includeNamespace(Namespace namespace) { + return true; + } + + @Override + public boolean includeTable(Table table) { + return VALIDATED_TABLES.contains(table.getName().toLowerCase(Locale.ROOT)); + } + + @Override + public boolean includeSequence(Sequence sequence) { + return false; + } + }; + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudSeedDataIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudSeedDataIntegrationTest.java new file mode 100644 index 0000000..4df5e59 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudSeedDataIntegrationTest.java @@ -0,0 +1,1036 @@ +package com.mydreamjob.skill; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_skill_seed;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@ActiveProfiles("integration-test") +class SkillCloudSeedDataIntegrationTest { + + private static final String EVIDENCE_RESOURCE = "skill-cloud/onet-31-reference.csv"; + private static final String EVIDENCE_HEADER = + "evidence_type,source_soc,element_id,data_value,workplace_example,source_file"; + private static final BigDecimal IMPORTANCE_TOLERANCE = new BigDecimal("0.001"); + private static final Set IMPORTANCE_SOURCE_FILES = Set.of( + "work_activities.csv", "knowledge.csv", "essential_skills.csv", "transferable_skills.csv"); + + private static final Pattern ONET_SKILL_SOURCE_PATTERN = + Pattern.compile("^\\d{2}-\\d{4}\\.\\d{2}\\|[^|]+\\|2\\.E\\.[0-9.a-z]+$"); + + private static final Map> EXPECTED_CATEGORIES = Map.ofEntries( + Map.entry("FRONTEND_DEVELOPER", List.of( + "编程语言", "Web 基础", "前端框架", "工程化工具", "浏览器与调试", "测试", "版本控制与协作")), + Map.entry("BACKEND_DEVELOPER", List.of( + "编程语言", "后端框架与平台", "数据库", "缓存与消息", "云/DevOps", "测试", "工程化工具", "安全")), + Map.entry("QA_ENGINEER", List.of( + "编程语言", "测试设计", "自动化测试框架", "接口与数据库测试", "性能测试", "CI/CD 与工程化工具")), + Map.entry("DEVOPS_ENGINEER", List.of( + "编程语言与脚本", "云平台", "容器与编排", "CI/CD", "配置管理与 IaC", "可观测性", "安全", "数据库")), + Map.entry("FINANCIAL_DATA_ANALYST", List.of( + "编程语言", "数据分析与可视化", "数据库与数据仓库", "统计与建模", "金融计算框架/平台", "金融领域知识", "工程化工具")), + Map.entry("RISK_MODELING_ENGINEER", List.of( + "编程语言", "统计与建模", "风险模型", "建模框架/平台", "数据处理与数据库", "模型验证", "部署与工程化工具")), + Map.entry("QUANTITATIVE_DEVELOPER", List.of( + "编程语言", "量化建模框架/平台", "市场与金融数据", "数据库", "低延迟与高性能工程", "测试", "云/DevOps")), + Map.entry("EDUCATION_PRODUCT_MANAGER", List.of( + "教育领域知识", "产品分析方法", "需求与流程", "数据分析", "教育平台与工具", "协作与工程化工具")), + Map.entry("INSTRUCTIONAL_DESIGNER", List.of( + "教学设计", "课程与学习平台", "数字化内容工具", "评估设计", "学习数据分析", "无障碍与规范")), + Map.entry("EDUCATION_DATA_ANALYST", List.of( + "编程语言", "数据分析与可视化", "数据库与 SQL", "统计与建模", "教育领域知识", "报表与工程化工具")) + ); + + private static final Map EXPECTED_ESCO_OCCUPATION_SOURCES = Map.ofEntries( + Map.entry("FRONTEND_DEVELOPER", + "http://data.europa.eu/esco/occupation/c40a2919-48a9-40ea-b506-1f34f693496d"), + Map.entry("BACKEND_DEVELOPER", + "http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1"), + Map.entry("QA_ENGINEER", + "http://data.europa.eu/esco/occupation/106f79e4-6264-45f1-9e7a-297435cd684b"), + Map.entry("DEVOPS_ENGINEER", + "http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1"), + Map.entry("FINANCIAL_DATA_ANALYST", + "http://data.europa.eu/esco/occupation/8d586ee9-0ab1-4155-a3b0-2ca786c8e48c"), + Map.entry("RISK_MODELING_ENGINEER", + "http://data.europa.eu/esco/occupation/b09acdda-6680-49d1-a411-129610ee3bc0"), + Map.entry("QUANTITATIVE_DEVELOPER", + "http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1"), + Map.entry("EDUCATION_PRODUCT_MANAGER", + "http://data.europa.eu/esco/occupation/a79170ad-0b7b-4697-864f-1748359aa10a"), + Map.entry("INSTRUCTIONAL_DESIGNER", + "http://data.europa.eu/esco/occupation/a9c30651-ccbc-4a80-900c-7880615cdf6e"), + Map.entry("EDUCATION_DATA_ANALYST", + "http://data.europa.eu/esco/occupation/d3edb8f8-3a06-47a0-8fb9-9b212c006aa2") + ); + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockBean + private RedisTemplate redisTemplate; + + @Test + void seedDataCoversIndustriesOccupationsAndEveryDeclaredCategory() { + assertEquals(3L, count("SELECT COUNT(*) FROM skill_industries")); + assertEquals(10L, count("SELECT COUNT(*) FROM skill_occupations")); + assertEquals(68L, count("SELECT COUNT(*) FROM skill_categories")); + + for (Map.Entry> entry : EXPECTED_CATEGORIES.entrySet()) { + String occupationCode = entry.getKey(); + List actualCategories = jdbcTemplate.queryForList( + "SELECT c.name_zh " + + "FROM skill_categories c " + + "JOIN skill_occupations o ON o.id = c.occupation_id " + + "WHERE o.code = ? " + + "ORDER BY c.sort_order, c.id", + String.class, occupationCode); + assertEquals(entry.getValue(), actualCategories, + "Category coverage mismatch for " + occupationCode); + + for (String categoryName : entry.getValue()) { + long relations = count( + "SELECT COUNT(*) FROM occupation_skills os " + + "JOIN skill_occupations o ON o.id = os.occupation_id " + + "JOIN skill_categories c ON c.id = os.category_id " + + "WHERE o.code = ? AND c.name_zh = ?", + occupationCode, categoryName); + assertTrue(relations >= 1L, + "Category has no skill relation: occupation=" + occupationCode + + ", category=" + categoryName + ", actualRelations=" + relations); + } + + Map coverage = jdbcTemplate.queryForMap( + "SELECT COUNT(os.skill_id) AS skill_count, " + + "SUM(CASE WHEN os.importance IS NOT NULL THEN 1 ELSE 0 END) AS weighted_count " + + "FROM skill_occupations o " + + "LEFT JOIN occupation_skills os ON os.occupation_id = o.id " + + "WHERE o.code = ? " + + "GROUP BY o.id", + occupationCode); + assertTrue(number(coverage.get("skill_count")) >= 10, + occupationCode + " must have at least ten skills"); + assertTrue(number(coverage.get("weighted_count")) >= 10, + occupationCode + " must have at least ten weighted skills"); + } + } + + @Test + void occupationNodesUseApprovedEscoUrisAndOnetSkillSourcesAreTraceable() { + assertEquals(10L, count( + "SELECT COUNT(*) FROM skill_occupations " + + "WHERE source_version_id = 1 " + + "AND source_code LIKE 'http://data.europa.eu/esco/occupation/%'")); + + for (Map.Entry entry : EXPECTED_ESCO_OCCUPATION_SOURCES.entrySet()) { + assertEquals(1L, count( + "SELECT COUNT(*) FROM skill_occupations o " + + "JOIN skill_source_versions sv ON sv.id = o.source_version_id " + + "WHERE o.code = ? AND o.source_code = ? AND sv.source_name = 'ESCO'", + entry.getKey(), entry.getValue()), + "Unexpected ESCO source for " + entry.getKey()); + } + + Map snapshot = loadEvidence("SOFTWARE"); + List> rows = jdbcTemplate.queryForList( + "SELECT id, code, source_code FROM skills WHERE source_type = 'O*NET' ORDER BY id"); + assertEquals(47, rows.size(), "Unexpected O*NET skill source count"); + + for (Map row : rows) { + long skillId = number(row.get("id")); + String skillCode = (String) row.get("code"); + String sourceCode = (String) row.get("source_code"); + assertTrue(ONET_SKILL_SOURCE_PATTERN.matcher(sourceCode).matches(), + "Invalid O*NET software_skills source: skillId=" + skillId + + ", skillCode=" + skillCode + ", sourceCode=" + sourceCode); + + String[] parts = sourceCode.split("\\|", -1); + EvidenceRow evidence = snapshot.get(softwareKey(parts[0], parts[1], parts[2])); + assertNotNull(evidence, + "Missing SOFTWARE evidence snapshot: skillId=" + skillId + + ", skillCode=" + skillCode + ", expectedSourceCode=" + sourceCode); + assertEquals("software_skills.csv", evidence.sourceFile(), + "Unexpected source file for skillId=" + skillId + ", skillCode=" + skillCode); + } + + assertEquals(rows.size(), snapshot.size(), + "SOFTWARE snapshot must contain exactly the 47 referenced O*NET source triples"); + + assertOnetSkillSource("REACT", "15-1254.00|React|2.E.7.h"); + assertOnetSkillSource("GOOGLE_CHROME", "17-2051.02|Google Chrome|2.E.10.e"); + assertOnetSkillSource("APACHE_KAFKA", "15-1299.07|Apache Kafka|2.E.7.b"); + assertOnetSkillSource("GITLAB", "15-1299.08|GitLab|2.E.10.a"); + assertOnetSkillSource("ARTICULATE_STORYLINE", "25-9031.00|Articulate Storyline|2.E.8.b"); + } + + @Test + void everyWeightHasTraceableOnetProvenanceAndKnownRepresentativeValue() { + assertEquals(139L, count("SELECT COUNT(*) FROM occupation_skills")); + assertEquals(2L, count("SELECT COUNT(*) FROM occupation_skills WHERE importance IS NULL")); + assertEquals(0L, count( + "SELECT COUNT(*) FROM occupation_skills " + + "WHERE importance IS NOT NULL " + + "AND (importance_source_code IS NULL OR TRIM(importance_source_code) = '' " + + "OR importance_element_id IS NULL OR TRIM(importance_element_id) = '')")); + assertEquals(0L, count( + "SELECT COUNT(*) FROM occupation_skills " + + "WHERE importance_is_proxy IS NULL OR importance_is_proxy NOT IN (FALSE, TRUE)")); + assertEquals(0L, count( + "SELECT COUNT(*) FROM occupation_skills os " + + "JOIN skill_source_versions sv ON sv.id = os.source_version_id " + + "WHERE os.importance IS NOT NULL AND sv.source_name <> 'O*NET'")); + + Map snapshot = loadEvidence("IMPORTANCE"); + List> rows = jdbcTemplate.queryForList( + "SELECT os.id, o.code AS occupation_code, s.code AS skill_code, os.importance, " + + "os.importance_source_code, os.importance_element_id, os.importance_is_proxy " + + "FROM occupation_skills os " + + "JOIN skill_occupations o ON o.id = os.occupation_id " + + "JOIN skills s ON s.id = os.skill_id " + + "WHERE os.importance IS NOT NULL " + + "ORDER BY os.id"); + assertEquals(137, rows.size(), "Unexpected non-null Importance relation count"); + + for (Map row : rows) { + long relationId = number(row.get("id")); + String occupationCode = (String) row.get("occupation_code"); + String skillCode = (String) row.get("skill_code"); + String sourceCode = (String) row.get("importance_source_code"); + String elementId = (String) row.get("importance_element_id"); + BigDecimal importance = (BigDecimal) row.get("importance"); + int proxy = toInteger(row.get("importance_is_proxy")); + + assertTrue(proxy == 0 || proxy == 1, + "Invalid importance_is_proxy: relationId=" + relationId + + ", occupationCode=" + occupationCode + ", skillCode=" + skillCode + + ", actual=" + proxy); + assertNotNull(sourceCode, + "Missing Importance SOC: relationId=" + relationId + + ", occupationCode=" + occupationCode + ", skillCode=" + skillCode); + assertNotNull(elementId, + "Missing Importance Element ID: relationId=" + relationId + + ", occupationCode=" + occupationCode + ", skillCode=" + skillCode); + assertNotNull(importance, + "Missing Importance value: relationId=" + relationId + + ", occupationCode=" + occupationCode + ", skillCode=" + skillCode); + + EvidenceRow evidence = snapshot.get(importanceKey(sourceCode, elementId)); + assertNotNull(evidence, + "Missing IMPORTANCE evidence snapshot: relationId=" + relationId + + ", occupationCode=" + occupationCode + ", skillCode=" + skillCode + + ", expectedSource=" + sourceCode + "|" + elementId + + "|" + importance); + assertNotNull(evidence.dataValue(), + "IMPORTANCE snapshot has empty Data Value: relationId=" + relationId + + ", sourceCode=" + sourceCode + ", elementId=" + elementId); + BigDecimal difference = evidence.dataValue().subtract(importance).abs(); + assertTrue(difference.compareTo(IMPORTANCE_TOLERANCE) <= 0, + "Importance mismatch: relationId=" + relationId + + ", occupationCode=" + occupationCode + ", skillCode=" + skillCode + + ", sourceCode=" + sourceCode + ", elementId=" + elementId + + ", expected=" + evidence.dataValue() + ", actual=" + importance + + ", absoluteDifference=" + difference + + ", tolerance=" + IMPORTANCE_TOLERANCE); + } + + assertEquals(rows.stream() + .map(row -> importanceKey( + (String) row.get("importance_source_code"), + (String) row.get("importance_element_id"))) + .distinct() + .count(), + snapshot.size(), + "IMPORTANCE snapshot must contain exactly the referenced unique SOC/Element ID combinations"); + + assertImportance("FRONTEND_DEVELOPER", "JAVASCRIPT", + "15-1254.00", "4.A.3.b.1", "4.88"); + assertImportance("BACKEND_DEVELOPER", "JAVA", + "15-1252.00", "4.A.3.b.1", "4.61"); + assertImportance("QA_ENGINEER", "SELENIUM", + "15-1253.00", "4.A.3.b.1", "4.94"); + assertImportance("DEVOPS_ENGINEER", "DOCKER", + "15-1244.00", "4.A.3.b.1", "4.83"); + assertImportance("FINANCIAL_DATA_ANALYST", "PYTHON", + "13-2031.00", "4.A.3.b.1", "4.48"); + assertImportance("RISK_MODELING_ENGINEER", "PYTHON", + "13-2053.00", "4.A.3.b.1", "4.41"); + assertImportance("QUANTITATIVE_DEVELOPER", "CPP", + "13-2099.01", "4.A.3.b.1", "4.68"); + assertImportance("EDUCATION_PRODUCT_MANAGER", "PROJECT_MANAGEMENT", + "13-1082.00", "4.A.2.b.4", "3.73"); + assertImportance("INSTRUCTIONAL_DESIGNER", "INSTRUCTIONAL_DESIGN", + "25-9031.00", "2.C.6", "4.78"); + assertImportance("EDUCATION_DATA_ANALYST", "PYTHON", + "15-2051.00", "4.A.3.b.1", "4.75"); + + assertNullImportance("FRONTEND_DEVELOPER", "FIGMA"); + assertNullImportance("INSTRUCTIONAL_DESIGNER", "ADOBE_PHOTOSHOP"); + } + + @Test + void evidenceLookupRejectsMissingOrTamperedOnetRows() { + Map software = loadEvidence("SOFTWARE"); + assertTrue(hasSoftware(software, "15-1254.00", "React", "2.E.7.h"), + "Known SOFTWARE evidence row should be present"); + assertFalse(hasSoftware(software, "99-9999.00", "React", "2.E.7.h"), + "Missing SOC must not match SOFTWARE evidence"); + assertFalse(hasSoftware(software, "15-1254.00", "Not React", "2.E.7.h"), + "Missing workplace example must not match SOFTWARE evidence"); + assertFalse(hasSoftware(software, "15-1254.00", "React", "9.Z.9.z"), + "Missing Element ID must not match SOFTWARE evidence"); + + Map importance = loadEvidence("IMPORTANCE"); + EvidenceRow sample = importance.values().iterator().next(); + assertTrue(hasImportance(importance, sample.sourceSoc(), sample.elementId(), sample.dataValue()), + "Known IMPORTANCE evidence row should be present"); + assertFalse(hasImportance(importance, "99-9999.00", sample.elementId(), sample.dataValue()), + "Missing SOC must not match IMPORTANCE evidence"); + assertFalse(hasImportance(importance, sample.sourceSoc(), "9.Z.9.z", sample.dataValue()), + "Missing Element ID must not match IMPORTANCE evidence"); + assertFalse(hasImportance(importance, sample.sourceSoc(), sample.elementId(), + sample.dataValue().add(new BigDecimal("0.01"))), + "Tampered Data Value must not match IMPORTANCE evidence"); + } + + @Test + void skillsHaveChineseNamesAliasesAndTraceableSourceVersions() { + assertEquals(0L, count( + "SELECT COUNT(*) FROM skills WHERE name_zh IS NULL OR TRIM(name_zh) = ''")); + assertEquals(0L, count( + "SELECT COUNT(*) FROM skills WHERE source_code IS NULL OR TRIM(source_code) = ''")); + assertEquals(0L, count( + "SELECT COUNT(*) FROM skills s " + + "WHERE NOT EXISTS (SELECT 1 FROM skill_aliases a WHERE a.skill_id = s.id)")); + assertEquals(0L, count( + "SELECT COUNT(*) FROM (" + + "SELECT normalized_alias FROM skill_aliases " + + "GROUP BY normalized_alias HAVING COUNT(*) > 1" + + ") duplicates")); + + assertEquals(0L, count( + "SELECT COUNT(*) FROM skills s " + + "JOIN skill_source_versions sv ON sv.id = s.source_version_id " + + "WHERE (s.source_type = 'ESCO' AND sv.source_name <> 'ESCO') " + + "OR (s.source_type = 'O*NET' AND sv.source_name <> 'O*NET')")); + } + + @Test + void sourceVersionsMatchTheApprovedManifest() { + assertEquals(1L, count( + "SELECT COUNT(*) FROM skill_source_versions " + + "WHERE source_name = 'ESCO' " + + "AND source_version = '1.2.1 (Release v5.7.0, Last update 10/12/2025)' " + + "AND license = 'Commission Decision 2011/833/EU' " + + "AND attribution_url = 'https://esco.ec.europa.eu/en/use-esco/download'")); + assertEquals(1L, count( + "SELECT COUNT(*) FROM skill_source_versions " + + "WHERE source_name = 'O*NET' " + + "AND source_version = '31.0 Database' " + + "AND license = 'CC BY 4.0' " + + "AND attribution_url = 'https://www.onetcenter.org/database.html'")); + assertEquals(2L, count("SELECT COUNT(*) FROM skill_source_versions")); + } + + @Test + void eachIndustryHasAtLeastOneRepresentativeSkill() { + assertTrue(hasSkillInIndustry("INTERNET", "JAVASCRIPT")); + assertTrue(hasSkillInIndustry("FINANCE", "FINANCIAL_ANALYSIS")); + assertTrue(hasSkillInIndustry("EDUCATION", "INSTRUCTIONAL_DESIGN")); + } + + + private static final String EXPECTED_MAPPINGS_RESOURCE = + "skill-cloud/skill-cloud-expected-mappings.csv"; + private static final String EXPECTED_MAPPINGS_HEADER = + "record_type,entity_key,expected_source_type,expected_source_code," + + "expected_element_id,expected_importance,expected_is_proxy"; + + @Test + void expectedMappingFileMatchesSeedAndSourceEvidenceExactly() { + Map expected = loadExpectedMappings(); + Map softwareEvidence = loadEvidence("SOFTWARE"); + Map importanceEvidence = loadEvidence("IMPORTANCE"); + + assertExpectedMappingCounts(expected); + verifyExpectedMappings(expected, softwareEvidence, importanceEvidence); + assertMappingVerifierRejectsTampering(expected); + } + + private Map loadExpectedMappings() { + Map mappings = new LinkedHashMap<>(); + try (InputStream input = new ClassPathResource(EXPECTED_MAPPINGS_RESOURCE).getInputStream(); + BufferedReader reader = new BufferedReader( + new InputStreamReader(input, StandardCharsets.UTF_8))) { + assertEquals(EXPECTED_MAPPINGS_HEADER, reader.readLine(), + "Unexpected expected-mappings header in classpath:" + EXPECTED_MAPPINGS_RESOURCE); + + String line; + int lineNumber = 1; + while ((line = reader.readLine()) != null) { + lineNumber++; + if (line.isBlank()) { + continue; + } + List values = parseCsvLine(line); + assertEquals(7, values.size(), + "Malformed expected-mappings line " + lineNumber + ": " + line); + String recordType = values.get(0); + String entityKey = values.get(1); + if (recordType.isBlank() || entityKey.isBlank()) { + throw expectedMappingFailure(recordType, entityKey, "row", + "non-empty record_type and entity_key", line); + } + ExpectedMapping mapping = new ExpectedMapping( + recordType, + entityKey, + emptyToNull(values.get(2)), + emptyToNull(values.get(3)), + emptyToNull(values.get(4)), + emptyToBigDecimal(values.get(5), recordType, entityKey), + emptyToInteger(values.get(6), recordType, entityKey)); + String key = expectedKey(recordType, entityKey); + ExpectedMapping previous = mappings.putIfAbsent(key, mapping); + if (previous != null) { + throw expectedMappingFailure(recordType, entityKey, "duplicate", + previous, mapping); + } + } + } catch (AssertionError error) { + throw error; + } catch (Exception exception) { + throw new AssertionError( + "Unable to load expected mappings from classpath:" + EXPECTED_MAPPINGS_RESOURCE, + exception); + } + return mappings; + } + + private void assertExpectedMappingCounts(Map mappings) { + Map expectedCounts = Map.of( + "OCCUPATION_SOURCE", 10, + "SKILL_SOURCE", 72, + "RELATION", 139); + Map actualCounts = new LinkedHashMap<>(); + expectedCounts.keySet().forEach(recordType -> actualCounts.put(recordType, 0)); + for (ExpectedMapping mapping : mappings.values()) { + actualCounts.merge(mapping.recordType(), 1, Integer::sum); + } + for (Map.Entry entry : expectedCounts.entrySet()) { + Integer actual = actualCounts.get(entry.getKey()); + if (!entry.getValue().equals(actual)) { + throw expectedMappingFailure(entry.getKey(), "", "count", + entry.getValue(), actual); + } + } + for (Map.Entry entry : actualCounts.entrySet()) { + if (!expectedCounts.containsKey(entry.getKey())) { + throw expectedMappingFailure(entry.getKey(), "", "record_type", + expectedCounts.keySet(), actualCounts.keySet()); + } + } + } + + private void verifyExpectedMappings( + Map expected, + Map softwareEvidence, + Map importanceEvidence) { + Map actual = loadActualMappings(); + assertSameMappingKeys(expected, actual); + + for (ExpectedMapping expectedMapping : expected.values()) { + assertValidExpectedMapping(expectedMapping); + ActualMapping actualMapping = actual.get(expectedKey( + expectedMapping.recordType(), expectedMapping.entityKey())); + if (actualMapping == null) { + throw expectedMappingFailure(expectedMapping.recordType(), expectedMapping.entityKey(), + "entity", "present", "missing"); + } + assertMappingMatches(expectedMapping, actualMapping); + } + + verifySkillEvidence(expected, softwareEvidence); + verifyRelationEvidence(expected, importanceEvidence); + } + + private void assertMappingVerifierRejectsTampering(Map expected) { + List onetSkills = expected.values().stream() + .filter(mapping -> "SKILL_SOURCE".equals(mapping.recordType())) + .filter(mapping -> "O*NET".equals(mapping.sourceType())) + .toList(); + if (onetSkills.size() < 2) { + throw new AssertionError("Falsifiability self-check requires at least two O*NET skills"); + } + ExpectedMapping firstSkill = onetSkills.get(0); + ExpectedMapping secondSkill = onetSkills.get(1); + ExpectedMapping swappedSource = new ExpectedMapping( + firstSkill.recordType(), firstSkill.entityKey(), firstSkill.sourceType(), + secondSkill.sourceCode(), firstSkill.elementId(), + firstSkill.importance(), firstSkill.isProxy()); + assertThrows(AssertionError.class, + () -> assertMappingMatches(swappedSource, actualFrom(firstSkill)), + "Swapping source_code must fail the generic mapping check"); + + ExpectedMapping weightedRelation = expected.values().stream() + .filter(mapping -> "RELATION".equals(mapping.recordType())) + .filter(mapping -> mapping.importance() != null) + .filter(mapping -> mapping.isProxy() != null) + .findFirst() + .orElseThrow(() -> new AssertionError("No weighted relation for self-check")); + ExpectedMapping toggledProxy = new ExpectedMapping( + weightedRelation.recordType(), weightedRelation.entityKey(), weightedRelation.sourceType(), + weightedRelation.sourceCode(), weightedRelation.elementId(), + weightedRelation.importance(), weightedRelation.isProxy() == 0 ? 1 : 0); + assertThrows(AssertionError.class, + () -> assertMappingMatches(toggledProxy, actualFrom(weightedRelation)), + "Toggling expected_is_proxy must fail the generic mapping check"); + + ExpectedMapping tamperedImportance = new ExpectedMapping( + weightedRelation.recordType(), weightedRelation.entityKey(), weightedRelation.sourceType(), + weightedRelation.sourceCode(), weightedRelation.elementId(), + weightedRelation.importance().add(new BigDecimal("0.01")), + weightedRelation.isProxy()); + assertThrows(AssertionError.class, + () -> assertMappingMatches(tamperedImportance, actualFrom(weightedRelation)), + "Tampering expected_importance must fail the generic mapping check"); + } + + private Map loadActualMappings() { + Map mappings = new LinkedHashMap<>(); + + List> occupationRows = jdbcTemplate.queryForList( + "SELECT o.code AS entity_key, sv.source_name AS source_type, o.source_code " + + "FROM skill_occupations o " + + "JOIN skill_source_versions sv ON sv.id = o.source_version_id"); + for (Map row : occupationRows) { + String entityKey = (String) row.get("entity_key"); + putActual(mappings, "OCCUPATION_SOURCE", entityKey, new ActualMapping( + (String) row.get("source_type"), + (String) row.get("source_code"), + null, + null, + null)); + } + + List> skillRows = jdbcTemplate.queryForList( + "SELECT code AS entity_key, source_type, source_code FROM skills"); + for (Map row : skillRows) { + String entityKey = (String) row.get("entity_key"); + putActual(mappings, "SKILL_SOURCE", entityKey, new ActualMapping( + (String) row.get("source_type"), + (String) row.get("source_code"), + null, + null, + null)); + } + + List> relationRows = jdbcTemplate.queryForList( + "SELECT o.code AS occupation_code, s.code AS skill_code, " + + "sv.source_name AS source_type, os.importance_source_code, " + + "os.importance_element_id, os.importance, os.importance_is_proxy " + + "FROM occupation_skills os " + + "JOIN skill_occupations o ON o.id = os.occupation_id " + + "JOIN skills s ON s.id = os.skill_id " + + "JOIN skill_source_versions sv ON sv.id = os.source_version_id"); + for (Map row : relationRows) { + String entityKey = row.get("occupation_code") + "|" + row.get("skill_code"); + putActual(mappings, "RELATION", entityKey, new ActualMapping( + (String) row.get("source_type"), + (String) row.get("importance_source_code"), + (String) row.get("importance_element_id"), + (BigDecimal) row.get("importance"), + toInteger(row.get("importance_is_proxy")))); + } + return mappings; + } + + private void putActual(Map mappings, String recordType, + String entityKey, ActualMapping mapping) { + String key = expectedKey(recordType, entityKey); + ActualMapping previous = mappings.putIfAbsent(key, mapping); + if (previous != null) { + throw expectedMappingFailure(recordType, entityKey, "duplicate", previous, mapping); + } + } + + private Integer toInteger(Object value) { + if (value == null) { + return null; + } + if (value instanceof Boolean booleanValue) { + return booleanValue ? 1 : 0; + } + return ((Number) value).intValue(); + } + + private void assertSameMappingKeys( + Map expected, Map actual) { + Set expectedKeys = new TreeSet<>(expected.keySet()); + Set actualKeys = new TreeSet<>(actual.keySet()); + if (!expectedKeys.equals(actualKeys)) { + Set missing = new TreeSet<>(expectedKeys); + missing.removeAll(actualKeys); + if (!missing.isEmpty()) { + String key = missing.iterator().next(); + throw expectedMappingFailure(keyPart(key, 0), keyPart(key, 1), + "entity", "present", "missing"); + } + Set extra = new TreeSet<>(actualKeys); + extra.removeAll(expectedKeys); + String key = extra.iterator().next(); + throw expectedMappingFailure(keyPart(key, 0), keyPart(key, 1), + "entity", "absent", "present"); + } + } + + private String keyPart(String key, int index) { + return key.split("\\u0000", -1)[index]; + } + + private void assertValidExpectedMapping(ExpectedMapping mapping) { + String recordType = mapping.recordType(); + String entityKey = mapping.entityKey(); + if ("OCCUPATION_SOURCE".equals(recordType)) { + assertExpectedField(mapping, "expected_source_type", "ESCO", mapping.sourceType()); + assertExpectedNonNull(mapping, "expected_source_code", mapping.sourceCode()); + assertExpectedField(mapping, "expected_element_id", null, mapping.elementId()); + assertExpectedField(mapping, "expected_importance", null, mapping.importance()); + assertExpectedField(mapping, "expected_is_proxy", null, mapping.isProxy()); + return; + } + if ("SKILL_SOURCE".equals(recordType)) { + if (!"ESCO".equals(mapping.sourceType()) && !"O*NET".equals(mapping.sourceType())) { + throw expectedMappingFailure(recordType, entityKey, "expected_source_type", + "ESCO or O*NET", mapping.sourceType()); + } + assertExpectedNonNull(mapping, "expected_source_code", mapping.sourceCode()); + assertExpectedField(mapping, "expected_element_id", null, mapping.elementId()); + assertExpectedField(mapping, "expected_importance", null, mapping.importance()); + assertExpectedField(mapping, "expected_is_proxy", null, mapping.isProxy()); + return; + } + if ("RELATION".equals(recordType)) { + assertExpectedField(mapping, "expected_source_type", "O*NET", mapping.sourceType()); + if (mapping.isProxy() == null) { + throw expectedMappingFailure(recordType, entityKey, "expected_is_proxy", + "0 or 1", null); + } + boolean hasProvenance = mapping.sourceCode() != null + || mapping.elementId() != null + || mapping.importance() != null; + boolean completeProvenance = mapping.sourceCode() != null + && mapping.elementId() != null + && mapping.importance() != null; + if (hasProvenance != completeProvenance) { + throw expectedMappingFailure(recordType, entityKey, "importance_provenance", + "all empty or all present", + mapping.sourceCode() + "|" + mapping.elementId() + "|" + mapping.importance()); + } + if (!completeProvenance && mapping.isProxy() != 0) { + throw expectedMappingFailure(recordType, entityKey, "expected_is_proxy", + 0, mapping.isProxy()); + } + return; + } + throw expectedMappingFailure(recordType, entityKey, "record_type", + "OCCUPATION_SOURCE|SKILL_SOURCE|RELATION", recordType); + } + + private void assertExpectedNonNull( + ExpectedMapping mapping, String field, Object actual) { + if (actual == null) { + throw expectedMappingFailure( + mapping.recordType(), mapping.entityKey(), field, "non-empty", null); + } + } + + private void assertExpectedField( + ExpectedMapping mapping, String field, Object expected, Object actual) { + if (expected == null ? actual != null : !expected.equals(actual)) { + throw expectedMappingFailure( + mapping.recordType(), mapping.entityKey(), field, expected, actual); + } + } + + private void assertMappingMatches(ExpectedMapping expected, ActualMapping actual) { + assertMappingField(expected.recordType(), expected.entityKey(), + "source_type", expected.sourceType(), actual.sourceType()); + assertMappingField(expected.recordType(), expected.entityKey(), + "source_code", expected.sourceCode(), actual.sourceCode()); + assertMappingField(expected.recordType(), expected.entityKey(), + "element_id", expected.elementId(), actual.elementId()); + + if (expected.importance() == null) { + assertMappingField(expected.recordType(), expected.entityKey(), + "importance", null, actual.importance()); + } else if (actual.importance() == null + || actual.importance().subtract(expected.importance()).abs() + .compareTo(IMPORTANCE_TOLERANCE) > 0) { + throw expectedMappingFailure(expected.recordType(), expected.entityKey(), + "importance", expected.importance(), actual.importance()); + } + + assertMappingField(expected.recordType(), expected.entityKey(), + "importance_is_proxy", expected.isProxy(), actual.isProxy()); + } + + private void assertMappingField( + String recordType, String entityKey, String field, Object expected, Object actual) { + if (expected == null ? actual != null : !expected.equals(actual)) { + throw expectedMappingFailure(recordType, entityKey, field, expected, actual); + } + } + + private void verifySkillEvidence( + Map expected, Map softwareEvidence) { + Set expectedSoftwareKeys = new TreeSet<>(); + for (ExpectedMapping mapping : expected.values()) { + if (!"SKILL_SOURCE".equals(mapping.recordType()) || !"O*NET".equals(mapping.sourceType())) { + continue; + } + String[] parts = mapping.sourceCode().split("\\|", -1); + if (parts.length != 3 + || !ONET_SKILL_SOURCE_PATTERN.matcher(mapping.sourceCode()).matches()) { + throw expectedMappingFailure(mapping.recordType(), mapping.entityKey(), + "expected_source_code", "SOC|Workplace Example|Element ID", mapping.sourceCode()); + } + String key = softwareKey(parts[0], parts[1], parts[2]); + EvidenceRow evidence = softwareEvidence.get(key); + if (evidence == null) { + throw expectedMappingFailure(mapping.recordType(), mapping.entityKey(), + "expected_source_code", "present in onet-31-reference.csv", "missing"); + } + if (!"software_skills.csv".equals(evidence.sourceFile())) { + throw expectedMappingFailure(mapping.recordType(), mapping.entityKey(), + "source_file", "software_skills.csv", evidence.sourceFile()); + } + if (evidence.dataValue() != null) { + throw expectedMappingFailure(mapping.recordType(), mapping.entityKey(), + "data_value", null, evidence.dataValue()); + } + expectedSoftwareKeys.add(key); + } + assertEvidenceKeySet("SOFTWARE", expectedSoftwareKeys, softwareEvidence.keySet()); + } + + private void verifyRelationEvidence( + Map expected, Map importanceEvidence) { + Set expectedImportanceKeys = new TreeSet<>(); + for (ExpectedMapping mapping : expected.values()) { + if (!"RELATION".equals(mapping.recordType()) || mapping.importance() == null) { + continue; + } + String key = importanceKey(mapping.sourceCode(), mapping.elementId()); + EvidenceRow evidence = importanceEvidence.get(key); + if (evidence == null) { + throw expectedMappingFailure(mapping.recordType(), mapping.entityKey(), + "importance_source_code", "present in onet-31-reference.csv", "missing"); + } + if (evidence.dataValue() == null + || evidence.dataValue().subtract(mapping.importance()).abs() + .compareTo(IMPORTANCE_TOLERANCE) > 0) { + throw expectedMappingFailure(mapping.recordType(), mapping.entityKey(), + "expected_importance", mapping.importance(), evidence.dataValue()); + } + expectedImportanceKeys.add(key); + } + assertEvidenceKeySet("IMPORTANCE", expectedImportanceKeys, importanceEvidence.keySet()); + } + + private void assertEvidenceKeySet( + String recordType, Set expectedKeys, Set actualKeys) { + Set missing = new TreeSet<>(expectedKeys); + missing.removeAll(actualKeys); + if (!missing.isEmpty()) { + throw expectedMappingFailure(recordType, missing.iterator().next(), + "evidence_key", "present", "missing"); + } + Set extra = new TreeSet<>(actualKeys); + extra.removeAll(expectedKeys); + if (!extra.isEmpty()) { + throw expectedMappingFailure(recordType, extra.iterator().next(), + "evidence_key", "absent", "present"); + } + } + + private ActualMapping actualFrom(ExpectedMapping mapping) { + return new ActualMapping( + mapping.sourceType(), + mapping.sourceCode(), + mapping.elementId(), + mapping.importance(), + mapping.isProxy()); + } + + private String expectedKey(String recordType, String entityKey) { + return recordType + '\u0000' + entityKey; + } + + private String emptyToNull(String value) { + return value == null || value.isBlank() ? null : value; + } + + private BigDecimal emptyToBigDecimal( + String value, String recordType, String entityKey) { + String normalized = emptyToNull(value); + if (normalized == null) { + return null; + } + try { + return new BigDecimal(normalized); + } catch (NumberFormatException exception) { + throw expectedMappingFailure(recordType, entityKey, "expected_importance", + "decimal or empty", value); + } + } + + private Integer emptyToInteger(String value, String recordType, String entityKey) { + String normalized = emptyToNull(value); + if (normalized == null) { + return null; + } + try { + int parsed = Integer.parseInt(normalized); + if (parsed != 0 && parsed != 1) { + throw new NumberFormatException("proxy must be 0 or 1"); + } + return parsed; + } catch (NumberFormatException exception) { + throw expectedMappingFailure(recordType, entityKey, "expected_is_proxy", + "0 or 1", value); + } + } + + private AssertionError expectedMappingFailure( + String recordType, String entityKey, String field, Object expected, Object actual) { + return new AssertionError( + "Expected mapping mismatch: record_type=" + recordType + + ", entity_key=" + entityKey + + ", field=" + field + + ", expected=" + expected + + ", actual=" + actual); + } + + private record ExpectedMapping( + String recordType, + String entityKey, + String sourceType, + String sourceCode, + String elementId, + BigDecimal importance, + Integer isProxy + ) { + } + + private record ActualMapping( + String sourceType, + String sourceCode, + String elementId, + BigDecimal importance, + Integer isProxy + ) { + } + + private Map loadEvidence(String evidenceType) { + Map rows = new LinkedHashMap<>(); + try (InputStream input = new ClassPathResource(EVIDENCE_RESOURCE).getInputStream(); + BufferedReader reader = new BufferedReader( + new InputStreamReader(input, StandardCharsets.UTF_8))) { + assertEquals(EVIDENCE_HEADER, reader.readLine(), + "Unexpected evidence snapshot header in classpath:" + EVIDENCE_RESOURCE); + + String line; + int lineNumber = 1; + while ((line = reader.readLine()) != null) { + lineNumber++; + if (line.isBlank()) { + continue; + } + List values = parseCsvLine(line); + assertEquals(6, values.size(), + "Malformed evidence snapshot line " + lineNumber + ": " + line); + if (!evidenceType.equals(values.get(0))) { + continue; + } + + String sourceSoc = values.get(1); + String elementId = values.get(2); + BigDecimal dataValue = values.get(3).isEmpty() + ? null : new BigDecimal(values.get(3)); + String workplaceExample = values.get(4); + String sourceFile = values.get(5); + + if ("SOFTWARE".equals(evidenceType)) { + assertEquals("software_skills.csv", sourceFile, + "Unexpected SOFTWARE source file at line " + lineNumber); + } else { + assertTrue(IMPORTANCE_SOURCE_FILES.contains(sourceFile), + "Unexpected IMPORTANCE source file at line " + lineNumber + + ": sourceFile=" + sourceFile); + } + + String key = "SOFTWARE".equals(evidenceType) + ? softwareKey(sourceSoc, workplaceExample, elementId) + : importanceKey(sourceSoc, elementId); + EvidenceRow evidence = new EvidenceRow( + evidenceType, sourceSoc, elementId, dataValue, workplaceExample, sourceFile); + EvidenceRow previous = rows.putIfAbsent(key, evidence); + if (previous != null) { + throw new AssertionError("Duplicate evidence row at line " + lineNumber + + ": evidenceType=" + evidenceType + + ", key=" + key + ", previous=" + previous + ", actual=" + evidence); + } + } + } catch (Exception exception) { + throw new AssertionError( + "Unable to load evidence snapshot from classpath:" + EVIDENCE_RESOURCE, exception); + } + return rows; + } + + private List parseCsvLine(String line) { + List values = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean quoted = false; + for (int index = 0; index < line.length(); index++) { + char character = line.charAt(index); + if (character == '"') { + if (quoted && index + 1 < line.length() && line.charAt(index + 1) == '"') { + current.append('"'); + index++; + } else { + quoted = !quoted; + } + } else if (character == ',' && !quoted) { + values.add(current.toString()); + current.setLength(0); + } else { + current.append(character); + } + } + assertFalse(quoted, "Unclosed quoted field in evidence snapshot line: " + line); + values.add(current.toString()); + return values; + } + + private boolean hasSoftware(Map rows, + String sourceSoc, String workplaceExample, String elementId) { + return rows.containsKey(softwareKey(sourceSoc, workplaceExample, elementId)); + } + + private boolean hasImportance(Map rows, + String sourceSoc, String elementId, BigDecimal expectedValue) { + if (expectedValue == null) { + return false; + } + EvidenceRow evidence = rows.get(importanceKey(sourceSoc, elementId)); + return evidence != null + && evidence.dataValue() != null + && evidence.dataValue().subtract(expectedValue).abs() + .compareTo(IMPORTANCE_TOLERANCE) <= 0; + } + + private String softwareKey(String sourceSoc, String workplaceExample, String elementId) { + return sourceSoc + '\u0000' + workplaceExample + '\u0000' + elementId; + } + + private String importanceKey(String sourceSoc, String elementId) { + return sourceSoc + '\u0000' + elementId; + } + + private void assertOnetSkillSource(String skillCode, String expectedSource) { + assertEquals(1L, count( + "SELECT COUNT(*) FROM skills " + + "WHERE code = ? AND source_type = 'O*NET' AND source_code = ?", + skillCode, expectedSource), "Unexpected O*NET source for " + skillCode); + } + + private void assertImportance(String occupationCode, String skillCode, + String sourceCode, String elementId, String expectedValue) { + Map row = jdbcTemplate.queryForMap( + "SELECT os.importance, os.importance_source_code, os.importance_element_id, " + + "os.importance_is_proxy " + + "FROM occupation_skills os " + + "JOIN skill_occupations o ON o.id = os.occupation_id " + + "JOIN skills s ON s.id = os.skill_id " + + "WHERE o.code = ? AND s.code = ?", + occupationCode, skillCode); + assertEquals(new BigDecimal(expectedValue), ((BigDecimal) row.get("importance")).setScale(2)); + assertEquals(sourceCode, row.get("importance_source_code")); + assertEquals(elementId, row.get("importance_element_id")); + assertEquals(1, toInteger(row.get("importance_is_proxy"))); + } + + private void assertNullImportance(String occupationCode, String skillCode) { + Map row = jdbcTemplate.queryForMap( + "SELECT os.importance, os.importance_source_code, os.importance_element_id, " + + "os.importance_is_proxy " + + "FROM occupation_skills os " + + "JOIN skill_occupations o ON o.id = os.occupation_id " + + "JOIN skills s ON s.id = os.skill_id " + + "WHERE o.code = ? AND s.code = ?", + occupationCode, skillCode); + assertNull(row.get("importance")); + assertNull(row.get("importance_source_code")); + assertNull(row.get("importance_element_id")); + assertEquals(0, toInteger(row.get("importance_is_proxy"))); + } + + private boolean hasSkillInIndustry(String industryCode, String skillCode) { + return count( + "SELECT COUNT(*) FROM occupation_skills os " + + "JOIN skill_occupations o ON o.id = os.occupation_id " + + "JOIN skill_industries i ON i.id = o.industry_id " + + "JOIN skills s ON s.id = os.skill_id " + + "WHERE i.code = ? AND s.code = ?", + industryCode, skillCode) > 0L; + } + + private long count(String sql, Object... args) { + Long result = jdbcTemplate.queryForObject(sql, Long.class, args); + assertNotNull(result); + return result; + } + + private long number(Object value) { + assertNotNull(value); + return ((Number) value).longValue(); + } + + private record EvidenceRow( + String evidenceType, + String sourceSoc, + String elementId, + BigDecimal dataValue, + String workplaceExample, + String sourceFile + ) { + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillLearningPathServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillLearningPathServiceTest.java new file mode 100644 index 0000000..5143d58 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillLearningPathServiceTest.java @@ -0,0 +1,416 @@ +package com.mydreamjob.skill; + +import com.mydreamjob.skill.domain.SkillCategory; +import com.mydreamjob.skill.dto.SkillCloudItem; +import com.mydreamjob.skill.dto.SkillLearningPathItem; +import com.mydreamjob.skill.dto.SkillLearningPathResponse; +import com.mydreamjob.skill.dto.SkillMatchResponse; +import com.mydreamjob.skill.repository.SkillCategoryRepository; +import org.hibernate.boot.model.relational.Namespace; +import org.hibernate.boot.model.relational.Sequence; +import org.hibernate.mapping.Table; +import org.hibernate.tool.schema.spi.SchemaFilter; +import org.hibernate.tool.schema.spi.SchemaFilterProvider; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@SpringBootTest +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:integrationdb_skill_learning_path;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "spring.jpa.hibernate.ddl-auto=validate", + "spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=com.mydreamjob.skill.SkillLearningPathServiceTest$SkillLearningPathSchemaFilterProvider" +}) +@ActiveProfiles("integration-test") +@Transactional +class SkillLearningPathServiceTest { + + private static final Long USER_ID = 7001L; + private static final Long INDUSTRY_ID = 1L; + private static final Long OCCUPATION_ID = 1L; + private static final Long CATEGORY_ID = 101L; + private static final Long EDUCATION_INDUSTRY_ID = 3L; + private static final Long INSTRUCTIONAL_DESIGNER_OCCUPATION_ID = 9L; + + @Autowired + private SkillLearningPathService learningPathService; + + @Autowired + private SkillMatchService matchService; + + @MockBean + private RedisTemplate redisTemplate; + + @Test + void v36LearningPathReusesMatchMissingSkillsWithoutReordering() { + SkillMatchResponse matchResponse = matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null); + SkillLearningPathResponse response = learningPathService.generate( + USER_ID, INDUSTRY_ID, OCCUPATION_ID, null); + + assertThat(matchResponse.missingSkills()).hasSize(10); + assertThat(response.total()).isEqualTo(matchResponse.missingSkills().size()); + assertThat(response.items()) + .extracting(SkillLearningPathItem::skillId) + .containsExactlyElementsOf(matchResponse.missingSkills().stream() + .map(SkillCloudItem::skillId) + .toList()); + assertThat(response.items()) + .extracting(SkillLearningPathItem::priority) + .containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + assertThat(response.items()).allSatisfy(item -> { + assertThat(item.skillName()).isNotBlank(); + assertThat(item.categoryId()).isNotNull(); + assertThat(item.categoryName()).isNotBlank(); + assertThat(item.suggestion()).contains(item.skillName()); + assertNoForbiddenContent(item.suggestion()); + }); + } + + @Test + void v36LearningPathPreservesMatchOrderAcrossHeatGroupsWhenNameOrderDiffers() { + SkillMatchResponse matchResponse = matchService.match( + USER_ID, EDUCATION_INDUSTRY_ID, INSTRUCTIONAL_DESIGNER_OCCUPATION_ID, null); + SkillLearningPathResponse response = learningPathService.generate( + USER_ID, EDUCATION_INDUSTRY_ID, INSTRUCTIONAL_DESIGNER_OCCUPATION_ID, null); + + List missingSkills = matchResponse.missingSkills(); + List missingNames = missingSkills.stream() + .map(SkillCloudItem::nameZh) + .toList(); + List missingIds = missingSkills.stream() + .map(SkillCloudItem::skillId) + .toList(); + List distinctHeats = missingSkills.stream() + .map(SkillCloudItem::heat) + .distinct() + .toList(); + + assertThat(missingSkills).hasSize(10); + assertThat(distinctHeats.size()).isGreaterThan(1); + assertThat(missingNames).isNotEqualTo(missingNames.stream().sorted().toList()); + assertThat(missingIds).isNotEqualTo(missingIds.stream().sorted().toList()); + assertThat(response.total()).isEqualTo(missingSkills.size()); + assertThat(response.items()) + .extracting(SkillLearningPathItem::skillId) + .containsExactlyElementsOf(missingIds); + assertThat(response.items()) + .extracting(SkillLearningPathItem::skillName) + .containsExactlyElementsOf(missingNames); + assertThat(response.items()) + .extracting(SkillLearningPathItem::priority) + .containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + } + + @Nested + class TemplateAndOrderingTests { + + private final SkillMatchService matchService = mock(SkillMatchService.class); + private final SkillCategoryRepository categoryRepository = mock(SkillCategoryRepository.class); + private final SkillLearningPathService service = + new SkillLearningPathService(matchService, categoryRepository); + + @Test + void usesOnlyMissingSkillsAndPreservesMissingOrderWithContiguousPriorities() { + SkillCloudItem matched = skill(11L, "Matched", "Matched", CATEGORY_ID, + "编程语言", new BigDecimal("9.00")); + List missing = List.of( + skill(30L, "Skill Z", "Skill Z", 101L, "编程语言", new BigDecimal("1.00")), + skill(10L, "Skill A", "Skill A", 102L, "框架", new BigDecimal("10.00")), + skill(20L, "Skill M", "Skill M", 103L, "工具", new BigDecimal("5.00")) + ); + List missingNames = missing.stream() + .map(SkillCloudItem::nameZh) + .toList(); + List missingIds = missing.stream() + .map(SkillCloudItem::skillId) + .toList(); + when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)) + .thenReturn(matchResponse(List.of(matched), missing)); + stubCategory(101L, "PROGRAMMING_LANGUAGE"); + stubCategory(102L, "FRAMEWORK"); + stubCategory(103L, "TOOL"); + + SkillLearningPathResponse response = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null); + + assertThat(response.total()).isEqualTo(3); + assertThat(missingNames).isNotEqualTo(missingNames.stream().sorted().toList()); + assertThat(missingIds).isNotEqualTo(missingIds.stream().sorted().toList()); + assertThat(response.items()) + .extracting(SkillLearningPathItem::skillId) + .containsExactly(30L, 10L, 20L); + assertThat(response.items()) + .extracting(SkillLearningPathItem::priority) + .containsExactly(1, 2, 3); + assertThat(response.items()) + .extracting(SkillLearningPathItem::skillName) + .containsExactly("Skill Z", "Skill A", "Skill M"); + assertThat(response.items()) + .noneMatch(item -> item.skillId().equals(matched.skillId())); + verify(matchService).match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null); + verifyNoMoreInteractions(matchService); + verify(categoryRepository).findByIdAndOccupationId(101L, OCCUPATION_ID); + verify(categoryRepository).findByIdAndOccupationId(102L, OCCUPATION_ID); + verify(categoryRepository).findByIdAndOccupationId(103L, OCCUPATION_ID); + verifyNoMoreInteractions(categoryRepository); + } + + @Test + void generatesProgrammingLanguageSuggestion() { + SkillCloudItem missing = skill(41L, "Java", "Java", CATEGORY_ID, + "编程语言", BigDecimal.ONE); + when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)) + .thenReturn(matchResponse(List.of(), List.of(missing))); + stubCategory(CATEGORY_ID, "PROGRAMMING_LANGUAGE"); + + SkillLearningPathItem item = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null) + .items().get(0); + + assertThat(item.suggestion()).isEqualTo( + "先掌握Java的基础语法和常见数据结构,再完成至少一个编码练习。"); + assertNoForbiddenContent(item.suggestion()); + } + + @Test + void generatesFrameworkSuggestion() { + SkillCloudItem missing = skill(42L, "Spring Boot", "Spring Boot", CATEGORY_ID, + "框架", BigDecimal.ONE); + when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)) + .thenReturn(matchResponse(List.of(), List.of(missing))); + stubCategory(CATEGORY_ID, "FRAMEWORK"); + + SkillLearningPathItem item = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null) + .items().get(0); + + assertThat(item.suggestion()).isEqualTo( + "先理解Spring Boot的核心概念与生命周期,再通过一个小型项目练习集成。"); + assertNoForbiddenContent(item.suggestion()); + } + + @Test + void generatesToolSuggestion() { + SkillCloudItem missing = skill(43L, "Docker", "Docker", CATEGORY_ID, + "工具", BigDecimal.ONE); + when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)) + .thenReturn(matchResponse(List.of(), List.of(missing))); + stubCategory(CATEGORY_ID, "TOOL"); + + SkillLearningPathItem item = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null) + .items().get(0); + + assertThat(item.suggestion()).isEqualTo( + "先完成Docker的安装与基础配置,再结合现有工程完成一次实际操作。"); + assertNoForbiddenContent(item.suggestion()); + } + + @Test + void generatesGeneralSuggestion() { + SkillCloudItem missing = skill(44L, "沟通能力", "Communication", CATEGORY_ID, + "通用能力", BigDecimal.ONE); + when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)) + .thenReturn(matchResponse(List.of(), List.of(missing))); + stubCategory(CATEGORY_ID, "GENERAL"); + + SkillLearningPathItem item = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null) + .items().get(0); + + assertThat(item.suggestion()).isEqualTo( + "先了解沟通能力的核心概念和常见应用场景,再通过一个简短实践巩固。"); + assertNoForbiddenContent(item.suggestion()); + } + + @Test + void fallsBackToGeneralForUnknownNullAndMissingTemplateCodes() { + List missing = List.of( + skill(51L, "Unknown", "Unknown", 101L, "分类 A", BigDecimal.ONE), + skill(52L, "NullCode", "NullCode", 102L, "分类 B", BigDecimal.ONE), + skill(53L, "MissingCategory", "MissingCategory", 103L, "分类 C", BigDecimal.ONE) + ); + when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)) + .thenReturn(matchResponse(List.of(), missing)); + when(categoryRepository.findByIdAndOccupationId(101L, OCCUPATION_ID)) + .thenReturn(Optional.of(category(101L, "UNKNOWN_TEMPLATE"))); + when(categoryRepository.findByIdAndOccupationId(102L, OCCUPATION_ID)) + .thenReturn(Optional.of(category(102L, null))); + when(categoryRepository.findByIdAndOccupationId(103L, OCCUPATION_ID)) + .thenReturn(Optional.empty()); + + SkillLearningPathResponse response = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null); + + assertThat(response.items()) + .extracting(SkillLearningPathItem::suggestion) + .containsExactly( + "先了解Unknown的核心概念和常见应用场景,再通过一个简短实践巩固。", + "先了解NullCode的核心概念和常见应用场景,再通过一个简短实践巩固。", + "先了解MissingCategory的核心概念和常见应用场景,再通过一个简短实践巩固。" + ); + response.items().forEach(item -> assertNoForbiddenContent(item.suggestion())); + } + + @Test + void returnsEmptyPathAndDoesNotReadCategoriesWhenThereAreNoGaps() { + SkillCloudItem matched = skill(61L, "Java", "Java", CATEGORY_ID, + "编程语言", BigDecimal.ONE); + when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)) + .thenReturn(matchResponse(List.of(matched), List.of())); + + SkillLearningPathResponse response = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null); + + assertThat(response.total()).isZero(); + assertThat(response.items()).isEmpty(); + verifyNoInteractions(categoryRepository); + } + + @Test + void repeatedCallsProduceIdenticalOrderPrioritiesAndText() { + List missing = List.of( + skill(71L, "Java", "Java", 101L, "编程语言", BigDecimal.ONE), + skill(72L, "Docker", "Docker", 102L, "工具", BigDecimal.ONE) + ); + when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)) + .thenReturn(matchResponse(List.of(), missing)); + stubCategory(101L, "PROGRAMMING_LANGUAGE"); + stubCategory(102L, "TOOL"); + + SkillLearningPathResponse first = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null); + SkillLearningPathResponse second = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null); + + assertThat(second).isEqualTo(first); + assertThat(second.items()) + .extracting(SkillLearningPathItem::skillId) + .containsExactly(71L, 72L); + assertThat(second.items()) + .extracting(SkillLearningPathItem::priority) + .containsExactly(1, 2); + assertThat(second.items()) + .extracting(SkillLearningPathItem::suggestion) + .containsExactly( + "先掌握Java的基础语法和常见数据结构,再完成至少一个编码练习。", + "先完成Docker的安装与基础配置,再结合现有工程完成一次实际操作。" + ); + } + + private void stubCategory(Long categoryId, String templateCode) { + when(categoryRepository.findByIdAndOccupationId(categoryId, OCCUPATION_ID)) + .thenReturn(Optional.of(category(categoryId, templateCode))); + } + } + + private static SkillMatchResponse matchResponse(List matched, + List missing) { + int total = matched.size() + missing.size(); + int score = total == 0 ? 0 : (int) Math.round(matched.size() * 100.0 / total); + return new SkillMatchResponse(score, matched.size(), total, matched, missing); + } + + private static SkillCloudItem skill(Long skillId, + String nameZh, + String nameEn, + Long categoryId, + String categoryName, + BigDecimal heat) { + return new SkillCloudItem( + skillId.intValue(), + skillId, + "SKILL_" + skillId, + nameZh, + nameEn, + "ESCO", + "source-" + skillId, + categoryId, + categoryName, + heat, + "15-1254.00", + "2.E.7.h", + false + ); + } + + private static SkillCategory category(Long categoryId, String templateCode) { + return SkillCategory.builder() + .id(categoryId) + .occupationId(OCCUPATION_ID) + .code("CATEGORY_" + categoryId) + .nameZh("分类 " + categoryId) + .suggestionTemplateCode(templateCode) + .sortOrder(categoryId.intValue()) + .build(); + } + + private static void assertNoForbiddenContent(String suggestion) { + assertThat(suggestion) + .doesNotContain("http://", "https://", "课程", "讲师", "价格", "时长", + "分钟", "小时", "完成度", "AI"); + } + + public static class SkillLearningPathSchemaFilterProvider implements SchemaFilterProvider { + + private static final Set VALIDATED_TABLES = Set.of( + "skill_source_versions", + "skill_industries", + "skill_occupations", + "skill_categories", + "skills", + "skill_aliases", + "occupation_skills" + ); + + @Override + public SchemaFilter getCreateFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getDropFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getTruncatorFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getMigrateFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getValidateFilter() { + return new SchemaFilter() { + @Override + public boolean includeNamespace(Namespace namespace) { + return true; + } + + @Override + public boolean includeTable(Table table) { + return VALIDATED_TABLES.contains(table.getName().toLowerCase(Locale.ROOT)); + } + + @Override + public boolean includeSequence(Sequence sequence) { + return false; + } + }; + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillMatchServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillMatchServiceTest.java new file mode 100644 index 0000000..d50b6e8 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillMatchServiceTest.java @@ -0,0 +1,482 @@ +package com.mydreamjob.skill; + +import com.mydreamjob.profile.StudentProfile; +import com.mydreamjob.profile.StudentProfileRepository; +import com.mydreamjob.skill.domain.SkillAlias; +import com.mydreamjob.skill.dto.SkillCloudItem; +import com.mydreamjob.skill.dto.SkillCloudNode; +import com.mydreamjob.skill.dto.SkillCloudResponse; +import com.mydreamjob.skill.dto.SkillMatchResponse; +import com.mydreamjob.skill.repository.SkillAliasRepository; +import org.hibernate.boot.model.relational.Namespace; +import org.hibernate.boot.model.relational.Sequence; +import org.hibernate.mapping.Table; +import org.hibernate.tool.schema.spi.SchemaFilter; +import org.hibernate.tool.schema.spi.SchemaFilterProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.Mockito.doReturn; + +@SpringBootTest +@ExtendWith(OutputCaptureExtension.class) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:integrationdb_skill_match;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "spring.jpa.hibernate.ddl-auto=validate", + "spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=com.mydreamjob.skill.SkillMatchServiceTest$SkillMatchSchemaFilterProvider" +}) +@ActiveProfiles("integration-test") +@Transactional +class SkillMatchServiceTest { + + private static final Long USER_ID = 7001L; + + @Autowired + private SkillMatchService service; + + @SpyBean + private SkillCloudQueryService queryService; + + @SpyBean + private StudentProfileRepository profileRepository; + + @SpyBean + private SkillAliasRepository aliasRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockBean + private RedisTemplate redisTemplate; + + @Test + void normalizerAppliesUnicodeRulesAndPreservesProgrammingSymbols() { + SkillNameNormalizer normalizer = new SkillNameNormalizer(); + + assertThat(normalizer.normalize(" Spring‐Boot. ")).isEqualTo("spring boot"); + assertThat(normalizer.normalize(" JAVA\n\tSCRIPT ")).isEqualTo("java script"); + assertThat(normalizer.normalize("Node.js")).isEqualTo("nodejs"); + assertThat(normalizer.normalize("C++")).isEqualTo("c++"); + assertThat(normalizer.normalize("C#")).isEqualTo("c#"); + } + + @Test + void usesOnlyTopTenWhenMoreThanTenSkillsExist() { + List items = IntStream.rangeClosed(1, 11) + .mapToObj(index -> item((long) index, "Skill " + index, "Skill " + index, "SKILL_" + index)) + .toList(); + stubCloud(cloud(items)); + saveProfile(USER_ID, json(items.stream().map(SkillCloudItem::nameZh).toList())); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + + assertThat(result.totalCount()).isEqualTo(10); + assertThat(result.matchedCount()).isEqualTo(10); + assertThat(result.matchScore()).isEqualTo(100); + assertThat(result.matchedSkills()) + .extracting(SkillCloudItem::skillId) + .containsExactlyElementsOf(IntStream.rangeClosed(1, 10).mapToLong(Long::valueOf).boxed().toList()); + assertThat(result.missingSkills()).isEmpty(); + } + + @Test + void usesTenAsDenominatorWhenExactlyTenSkillsExist() { + List items = IntStream.rangeClosed(1, 10) + .mapToObj(index -> item((long) index, "Skill " + index, "Skill " + index, "SKILL_" + index)) + .toList(); + stubCloud(cloud(items)); + saveProfile(USER_ID, json(items.stream().limit(5).map(SkillCloudItem::nameZh).toList())); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + + assertThat(result.totalCount()).isEqualTo(10); + assertThat(result.matchedCount()).isEqualTo(5); + assertThat(result.matchScore()).isEqualTo(50); + assertThat(result.missingSkills()).hasSize(5); + } + + @Test + void usesActualSizeAsDenominatorAndRoundsOneThirdToThirtyThree() { + List items = List.of( + item(1L, "Java", "Java", "JAVA"), + item(2L, "Python", "Python", "PYTHON"), + item(3L, "SQL", "SQL", "SQL") + ); + stubCloud(cloud(items)); + saveProfile(USER_ID, json(List.of("Java"))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + + assertThat(result.totalCount()).isEqualTo(3); + assertThat(result.matchedCount()).isEqualTo(1); + assertThat(result.matchScore()).isEqualTo(33); + assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L); + assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(2L, 3L); + } + + @Test + void returnsZeroAndEmptyListsWhenCloudIsEmpty() { + stubCloud(cloud(List.of())); + saveProfile(USER_ID, json(List.of("Java"))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + + assertThat(result.matchScore()).isZero(); + assertThat(result.matchedCount()).isZero(); + assertThat(result.totalCount()).isZero(); + assertThat(result.matchedSkills()).isEmpty(); + assertThat(result.missingSkills()).isEmpty(); + } + + @Test + void treatsMissingProfileAsNoSkills() { + stubCloud(cloud(List.of(item(1L, "Java", "Java", "JAVA")))); + + SkillMatchResponse result = service.match(9999L, 1L, 1L, null); + + assertThat(result.matchScore()).isZero(); + assertThat(result.matchedSkills()).isEmpty(); + assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L); + } + + @Test + void treatsBlankAndEmptyArrayAsNoSkills() { + stubCloud(cloud(List.of(item(1L, "Java", "Java", "JAVA"), item(2L, "SQL", "SQL", "SQL")))); + doReturn(Optional.of(StudentProfile.builder() + .id(100L) + .userId(USER_ID) + .skillsJson(" ") + .build())) + .when(profileRepository).findByUserId(USER_ID); + + SkillMatchResponse blank = service.match(USER_ID, 1L, 1L, null); + + assertThat(blank.matchScore()).isZero(); + assertThat(blank.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L); + + doReturn(Optional.of(StudentProfile.builder() + .id(101L) + .userId(USER_ID) + .skillsJson("[]") + .build())) + .when(profileRepository).findByUserId(USER_ID); + + SkillMatchResponse empty = service.match(USER_ID, 1L, 1L, null); + + assertThat(empty.matchScore()).isZero(); + assertThat(empty.matchedSkills()).isEmpty(); + } + + @Test + void treatsMalformedJsonAsNoSkillsWithoutLeakingRawJson(CapturedOutput output) { + String malformedJson = "{\"skills\":[\"Java\"]"; + stubCloud(cloud(List.of(item(1L, "Java", "Java", "JAVA")))); + doReturn(Optional.of(StudentProfile.builder() + .id(102L) + .userId(USER_ID) + .skillsJson(malformedJson) + .build())) + .when(profileRepository).findByUserId(USER_ID); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + + assertThat(result.matchScore()).isZero(); + assertThat(result.matchedSkills()).isEmpty(); + assertThat(output.getAll()).contains("Failed to parse student skills JSON"); + assertThat(output.getAll()).doesNotContain(malformedJson); + } + + @Test + void matchesNameZhNameEnSkillCodeAndAliasAndDeduplicatesStudentSkills() { + List items = List.of( + item(1L, "数据库", "Database Systems", "DATA_STORE_CLASS"), + item(2L, "中文技能", "database technology", "UNRELATED_CODE"), + item(3L, "代码技能", "Code English", "DATABASE_ENGINE") + ); + stubCloud(cloud(items)); + doReturn(List.of(alias(1L, "JVM Language", "jvm language"))) + .when(aliasRepository).findAllBySkillIdInOrderByIdAsc(anyCollection()); + saveProfile(USER_ID, json(List.of( + "jvm-language", + "JVM LANGUAGE", + "数据库", + "DATABASE TECHNOLOGY", + "database_engine" + ))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + + assertThat(result.matchScore()).isEqualTo(100); + assertThat(result.matchedCount()).isEqualTo(3); + assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L, 3L); + assertThat(result.missingSkills()).isEmpty(); + } + + @Test + void matchesOnlyNameZhForV36JavaWhenOtherIdentityFieldsDoNotMatch() { + jdbcTemplate.update("UPDATE skills SET code = ? WHERE id = ?", "JAVA_NAME_ZH_ISOLATED", 1L); + saveProfile(USER_ID, json(List.of("Java"))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 2L, 201L); + + assertThat(result.totalCount()).isEqualTo(2); + assertThat(result.matchScore()).isEqualTo(50); + assertThat(result.matchedSkills()) + .extracting(SkillCloudItem::skillId) + .containsExactly(1L); + assertThat(result.missingSkills()) + .extracting(SkillCloudItem::skillId) + .containsExactly(2L); + } + + @Test + void matchesOnlyInsertedAliasForV36JavaWhenAliasIsTheOnlyIdentityMatch() { + jdbcTemplate.update(""" + INSERT INTO skill_aliases (id, skill_id, alias, normalized_alias) + VALUES (?, ?, ?, ?) + """, 10001L, 1L, "ONLY.ALIAS-JVM-XYZ", "only alias jvm xyz"); + saveProfile(USER_ID, json(List.of("ONLY.ALIAS-JVM-XYZ"))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 2L, 201L); + + assertThat(result.totalCount()).isEqualTo(2); + assertThat(result.matchScore()).isEqualTo(50); + assertThat(result.matchedSkills()) + .extracting(SkillCloudItem::skillId) + .containsExactly(1L); + assertThat(result.missingSkills()) + .extracting(SkillCloudItem::skillId) + .containsExactly(2L); + } + + @Test + void doesNotApplyAliasOwnedByAnotherTopSkillToV36Java() { + jdbcTemplate.update(""" + INSERT INTO skill_aliases (id, skill_id, alias, normalized_alias) + VALUES (?, ?, ?, ?) + """, 10002L, 2L, "ONLY.ALIAS-OWNER-SKILL-2", "only alias owner skill 2"); + saveProfile(USER_ID, json(List.of("ONLY.ALIAS-OWNER-SKILL-2"))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 2L, 201L); + + assertThat(result.totalCount()).isEqualTo(2); + assertThat(result.matchScore()).isEqualTo(50); + assertThat(result.matchedSkills()) + .extracting(SkillCloudItem::skillId) + .containsExactly(2L); + assertThat(result.missingSkills()) + .extracting(SkillCloudItem::skillId) + .containsExactly(1L); + } + + @Test + void keepsMatchedAndMissingListsOrderedAndTheirUnionEqualToTopTen() { + List items = List.of( + item(1L, "Java", "Java", "JAVA"), + item(2L, "Python", "Python", "PYTHON"), + item(3L, "SQL", "SQL", "SQL") + ); + stubCloud(cloud(items)); + saveProfile(USER_ID, json(List.of("Python"))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + List union = Stream.concat(result.matchedSkills().stream(), result.missingSkills().stream()) + .map(SkillCloudItem::skillId) + .toList(); + + assertThat(result.matchScore()).isEqualTo(33); + assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(2L); + assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 3L); + assertThat(union).containsExactlyInAnyOrder(1L, 2L, 3L); + } + + @Test + void returnsZeroWhenNoTopSkillMatches() { + List items = List.of( + item(1L, "Java", "Java", "JAVA"), + item(2L, "SQL", "SQL", "SQL") + ); + stubCloud(cloud(items)); + saveProfile(USER_ID, json(List.of("Marketing"))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + + assertThat(result.matchScore()).isZero(); + assertThat(result.matchedSkills()).isEmpty(); + assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L); + } + + @Test + void returnsOneHundredAndNoMissingSkillsWhenAllTopSkillsMatch() { + List items = List.of( + item(1L, "Java", "Java", "JAVA"), + item(2L, "SQL", "SQL", "SQL") + ); + stubCloud(cloud(items)); + saveProfile(USER_ID, json(List.of("Java", "SQL"))); + + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + + assertThat(result.matchScore()).isEqualTo(100); + assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L); + assertThat(result.missingSkills()).isEmpty(); + } + + @Test + void excludesCrossOccupationCategoryMismatchFromMatchSet() { + jdbcTemplate.update(""" + INSERT INTO occupation_skills ( + id, occupation_id, skill_id, category_id, importance, + importance_source_code, importance_element_id, importance_is_proxy, + relationship_type, source_version_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + 10000L, 1L, 1L, 201L, new BigDecimal("5.00"), + "15-1254.00", "2.E.7.h", true, "ESSENTIAL", 2L); + saveProfile(USER_ID, json(List.of("Java"))); + + SkillCloudResponse cloud = queryService.query(1L, 1L, null); + SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null); + List expectedTop = cloud.items().stream().limit(10).map(SkillCloudItem::skillId).toList(); + List actualUnion = Stream.concat(result.matchedSkills().stream(), result.missingSkills().stream()) + .map(SkillCloudItem::skillId) + .toList(); + + assertThat(result.matchedCount()).isZero(); + assertThat(actualUnion).containsExactlyElementsOf(expectedTop); + assertThat(actualUnion).doesNotContain(1L); + } + + private void stubCloud(SkillCloudResponse response) { + doReturn(response).when(queryService).query(any(), any(), any()); + } + + private StudentProfile saveProfile(Long userId, String skillsJson) { + jdbcTemplate.update(""" + INSERT INTO users (id, phone, role, is_active, is_verified, created_at, updated_at) + VALUES (?, ?, 'STUDENT', TRUE, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, userId, String.valueOf(userId)); + return profileRepository.saveAndFlush(StudentProfile.builder() + .userId(userId) + .skillsJson(skillsJson) + .build()); + } + + private SkillCloudResponse cloud(List items) { + return new SkillCloudResponse( + new SkillCloudNode(1L, "INTERNET", "互联网"), + new SkillCloudNode(1L, "FRONTEND_DEVELOPER", "前端开发工程师"), + null, + items.size(), + items + ); + } + + private SkillCloudItem item(Long id, String nameZh, String nameEn, String code) { + return new SkillCloudItem( + id.intValue(), + id, + code, + nameZh, + nameEn, + "O*NET", + "test-source", + 1L, + "测试分类", + BigDecimal.TEN, + null, + null, + false + ); + } + + private SkillAlias alias(Long skillId, String alias, String normalizedAlias) { + return SkillAlias.builder() + .id(1L) + .skillId(skillId) + .alias(alias) + .normalizedAlias(normalizedAlias) + .build(); + } + + private String json(List values) { + return values.stream() + .map(value -> "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"") + .collect(java.util.stream.Collectors.joining(",", "[", "]")); + } + + public static class SkillMatchSchemaFilterProvider implements SchemaFilterProvider { + + private static final Set VALIDATED_TABLES = Set.of( + "skill_source_versions", + "skill_industries", + "skill_occupations", + "skill_categories", + "skills", + "skill_aliases", + "occupation_skills" + ); + + @Override + public SchemaFilter getCreateFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getDropFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getTruncatorFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getMigrateFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getValidateFilter() { + return new SchemaFilter() { + @Override + public boolean includeNamespace(Namespace namespace) { + return true; + } + + @Override + public boolean includeTable(Table table) { + return VALIDATED_TABLES.contains(table.getName().toLowerCase(Locale.ROOT)); + } + + @Override + public boolean includeSequence(Sequence sequence) { + return false; + } + }; + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyMigrationTest.java new file mode 100644 index 0000000..ad52d4b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyMigrationTest.java @@ -0,0 +1,372 @@ +package com.mydreamjob.skill; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import javax.sql.DataSource; +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_skill_taxonomy;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@ActiveProfiles("integration-test") +class SkillTaxonomyMigrationTest { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private DataSource dataSource; + + @MockBean + private RedisTemplate redisTemplate; + + private Long sourceVersionId; + private Long industryId; + private Long occupationId; + private Long categoryId; + private Long skillId; + private Long secondSkillId; + private Long occupationSkillId; + + @Test + void migrationCreatesTaxonomyTablesColumnsConstraintsAndIndexes() throws SQLException { + assertTableAndColumns("skill_source_versions", + "id", "source_name", "source_version", "license", "attribution_url", + "imported_at", "created_at", "updated_at"); + assertTableAndColumns("skill_industries", + "id", "code", "name_zh", "source_code", "sort_order", "created_at", "updated_at"); + assertTableAndColumns("skill_occupations", + "id", "industry_id", "code", "name_zh", "name_en", "source_code", + "source_version_id", "sort_order", "created_at", "updated_at"); + assertTableAndColumns("skill_categories", + "id", "occupation_id", "code", "name_zh", "suggestion_template_code", + "sort_order", "created_at", "updated_at"); + assertTableAndColumns("skills", + "id", "code", "name_zh", "name_en", "source_type", "source_code", + "source_version_id", "sort_order", "created_at", "updated_at"); + assertTableAndColumns("skill_aliases", + "id", "skill_id", "alias", "normalized_alias", "created_at"); + assertTableAndColumns("occupation_skills", + "id", "occupation_id", "skill_id", "category_id", "importance", "importance_source_code", "importance_element_id", "importance_is_proxy", + "relationship_type", "source_version_id", "created_at", "updated_at"); + + assertUniqueIndex("skill_source_versions", "source_name", "source_version"); + assertUniqueIndex("skill_industries", "code"); + assertUniqueIndex("skill_occupations", "industry_id", "code"); + assertUniqueIndex("skill_categories", "occupation_id", "code"); + assertUniqueIndex("skills", "code"); + assertUniqueIndex("skills", "source_type", "source_code"); + assertUniqueIndex("skill_aliases", "normalized_alias"); + assertUniqueIndex("skill_aliases", "skill_id", "alias"); + assertUniqueIndex("occupation_skills", "occupation_id", "skill_id"); + + assertForeignKey("skill_occupations", "industry_id", "skill_industries", "id"); + assertForeignKey("skill_occupations", "source_version_id", "skill_source_versions", "id"); + assertForeignKey("skill_categories", "occupation_id", "skill_occupations", "id"); + assertForeignKey("skills", "source_version_id", "skill_source_versions", "id"); + assertForeignKey("skill_aliases", "skill_id", "skills", "id"); + assertForeignKey("occupation_skills", "occupation_id", "skill_occupations", "id"); + assertForeignKey("occupation_skills", "skill_id", "skills", "id"); + assertForeignKey("occupation_skills", "category_id", "skill_categories", "id"); + assertForeignKey("occupation_skills", "source_version_id", "skill_source_versions", "id"); + + assertIndex("occupation_skills", "occupation_id", "category_id"); + assertIndex("occupation_skills", "occupation_id", "importance"); + assertBooleanColumnMetadata("occupation_skills", "importance_is_proxy"); + assertEquals(1L, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM information_schema.table_constraints " + + "WHERE UPPER(table_name) = 'OCCUPATION_SKILLS' " + + "AND UPPER(constraint_name) = 'CK_OCCUPATION_SKILLS_IMPORTANCE_PROXY' " + + "AND UPPER(constraint_type) = 'CHECK'", + Long.class)); + } + + @Test + void minimumTaxonomyChainIsUsableAndCoversCoreConstraints() { + sourceVersionId = insertAndReturnId( + "INSERT INTO skill_source_versions " + + "(source_name, source_version, license, attribution_url, imported_at) " + + "VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)", + "ESCO", "test-1.2.1", "Commission Decision 2011/833/EU", + "https://esco.ec.europa.eu/en/use-esco/download"); + industryId = insertAndReturnId( + "INSERT INTO skill_industries (code, name_zh, source_code, sort_order) " + + "VALUES (?, ?, ?, ?)", + "TEST_INTERNET", "互联网", "PROJECT-INTERNET", 1); + occupationId = insertAndReturnId( + "INSERT INTO skill_occupations " + + "(industry_id, code, name_zh, name_en, source_code, source_version_id, sort_order) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)", + industryId, "TEST_FRONTEND", "前端开发工程师", "Frontend Developer", + "ESCO-2513.2", sourceVersionId, 1); + categoryId = insertAndReturnId( + "INSERT INTO skill_categories " + + "(occupation_id, code, name_zh, suggestion_template_code, sort_order) " + + "VALUES (?, ?, ?, ?, ?)", + occupationId, "TEST_PROGRAMMING_LANGUAGE", "编程语言", + "PROGRAMMING_LANGUAGE", 1); + skillId = insertAndReturnId( + "INSERT INTO skills " + + "(code, name_zh, name_en, source_type, source_code, source_version_id, sort_order) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)", + "TEST_SKILL_JAVA", "Java", "Java", "ESCO", "ESCO-SKILL-JAVA", + sourceVersionId, 1); + insertAndReturnId( + "INSERT INTO skill_aliases (skill_id, alias, normalized_alias) VALUES (?, ?, ?)", + skillId, "Java SE", "java se"); + occupationSkillId = insertAndReturnId( + "INSERT INTO occupation_skills " + + "(occupation_id, skill_id, category_id, importance, relationship_type, source_version_id) " + + "VALUES (?, ?, ?, ?, ?, ?)", + occupationId, skillId, categoryId, null, "ESSENTIAL", sourceVersionId); + + Map joined = jdbcTemplate.queryForMap( + "SELECT s.name_zh AS skill_name, c.name_zh AS category_name, os.importance, " + + "os.importance_source_code, os.importance_element_id, os.importance_is_proxy " + + "FROM occupation_skills os " + + "JOIN skills s ON s.id = os.skill_id " + + "JOIN skill_categories c ON c.id = os.category_id " + + "WHERE os.id = ?", occupationSkillId); + assertEquals("Java", joined.get("skill_name")); + assertEquals("编程语言", joined.get("category_name")); + assertNull(joined.get("importance")); + assertNull(joined.get("importance_source_code")); + assertNull(joined.get("importance_element_id")); + assertEquals(Boolean.FALSE, joined.get("importance_is_proxy")); + assertEquals(1L, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM occupation_skills WHERE id = ? AND importance_is_proxy = FALSE", + Long.class, occupationSkillId)); + + assertConstraintFailure("UPDATE occupation_skills SET importance = ? WHERE id = ?", + new BigDecimal("4.25"), occupationSkillId); + jdbcTemplate.update("UPDATE occupation_skills SET importance = ?, " + + "importance_source_code = ?, importance_element_id = ?, importance_is_proxy = ? " + + "WHERE id = ?", + new BigDecimal("4.25"), "15-1252.00", "4.A.3.b.1", Boolean.TRUE, occupationSkillId); + Map weighted = jdbcTemplate.queryForMap( + "SELECT importance, importance_source_code, importance_element_id, importance_is_proxy " + + "FROM occupation_skills WHERE id = ?", + occupationSkillId); + assertEquals(new BigDecimal("4.25"), weighted.get("importance")); + assertEquals("15-1252.00", weighted.get("importance_source_code")); + assertEquals("4.A.3.b.1", weighted.get("importance_element_id")); + assertEquals(Boolean.TRUE, weighted.get("importance_is_proxy")); + assertEquals(1L, jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM occupation_skills WHERE id = ? AND importance_is_proxy = TRUE", + Long.class, occupationSkillId)); + assertConstraintFailure( + "INSERT INTO skill_source_versions " + + "(source_name, source_version, license, attribution_url) VALUES (?, ?, ?, ?)", + "ESCO", "test-1.2.1", "duplicate", "https://example.invalid"); + assertConstraintFailure( + "INSERT INTO skill_categories " + + "(occupation_id, code, name_zh, suggestion_template_code, sort_order) " + + "VALUES (?, ?, ?, ?, ?)", + occupationId, "TEST_INVALID_CATEGORY", "非法分类", "INVALID_TEMPLATE", 2); + assertConstraintFailure( + "INSERT INTO skills " + + "(code, name_zh, source_type, source_code, source_version_id, sort_order) " + + "VALUES (?, ?, ?, ?, ?, ?)", + "TEST_INVALID_SKILL", "非法技能", "INVALID_SOURCE", "INVALID-SOURCE-1", + sourceVersionId, 2); + secondSkillId = insertAndReturnId( + "INSERT INTO skills " + + "(code, name_zh, source_type, source_code, source_version_id, sort_order) " + + "VALUES (?, ?, ?, ?, ?, ?)", + "TEST_SKILL_SPRING", "Spring Boot", "ESCO", "ESCO-SKILL-SPRING", + sourceVersionId, 2); + assertConstraintFailure( + "INSERT INTO skills " + + "(code, name_zh, source_type, source_code, source_version_id, sort_order) " + + "VALUES (?, ?, ?, ?, ?, ?)", + "TEST_SKILL_NULL_NAME", null, "PROJECT", "PROJECT-NULL-NAME", + sourceVersionId, 3); + assertConstraintFailure( + "INSERT INTO occupation_skills " + + "(occupation_id, skill_id, category_id, relationship_type, source_version_id) " + + "VALUES (?, ?, ?, ?, ?)", + occupationId, secondSkillId, categoryId, "INVALID_RELATIONSHIP", sourceVersionId); + assertConstraintFailure( + "INSERT INTO skill_aliases (skill_id, alias, normalized_alias) VALUES (?, ?, ?)", + secondSkillId, "Java SE alternate", "java se"); + + assertConstraintFailure( + "UPDATE occupation_skills SET importance_is_proxy = ? WHERE id = ?", + (Object) null, occupationSkillId); + assertTrue(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM skill_aliases WHERE skill_id = ? AND normalized_alias = ?", + Integer.class, skillId, "java se") == 1); + } + + @AfterEach + void cleanUp() { + if (occupationSkillId != null) { + jdbcTemplate.update("DELETE FROM occupation_skills WHERE id = ?", occupationSkillId); + } + if (secondSkillId != null) { + jdbcTemplate.update("DELETE FROM skill_aliases WHERE skill_id = ?", secondSkillId); + jdbcTemplate.update("DELETE FROM skills WHERE id = ?", secondSkillId); + } + if (skillId != null) { + jdbcTemplate.update("DELETE FROM skill_aliases WHERE skill_id = ?", skillId); + jdbcTemplate.update("DELETE FROM skills WHERE id = ?", skillId); + } + if (categoryId != null) { + jdbcTemplate.update("DELETE FROM skill_categories WHERE id = ?", categoryId); + } + if (occupationId != null) { + jdbcTemplate.update("DELETE FROM skill_occupations WHERE id = ?", occupationId); + } + if (industryId != null) { + jdbcTemplate.update("DELETE FROM skill_industries WHERE id = ?", industryId); + } + if (sourceVersionId != null) { + jdbcTemplate.update("DELETE FROM skill_source_versions WHERE id = ?", sourceVersionId); + } + } + + private void assertBooleanColumnMetadata(String table, String column) { + Map metadata = jdbcTemplate.queryForMap( + "SELECT data_type, column_default, is_nullable FROM information_schema.columns " + + "WHERE UPPER(table_name) = ? AND UPPER(column_name) = ?", + table.toUpperCase(Locale.ROOT), column.toUpperCase(Locale.ROOT)); + assertEquals("BOOLEAN", String.valueOf(metadata.get("data_type")).toUpperCase(Locale.ROOT)); + assertEquals("FALSE", String.valueOf(metadata.get("column_default")).toUpperCase(Locale.ROOT)); + assertEquals("NO", String.valueOf(metadata.get("is_nullable")).toUpperCase(Locale.ROOT)); + } + + private void assertTableAndColumns(String table, String... columns) throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualTable = findTable(metadata, table); + assertTrue(actualTable != null, "Missing table: " + table); + + List actualColumns = new ArrayList<>(); + try (ResultSet resultSet = metadata.getColumns(null, null, actualTable, null)) { + while (resultSet.next()) { + actualColumns.add(resultSet.getString("COLUMN_NAME").toLowerCase()); + } + } + for (String column : columns) { + assertTrue(actualColumns.contains(column.toLowerCase()), + "Missing column " + table + "." + column + "; actual=" + actualColumns); + } + } + } + + private void assertUniqueIndex(String table, String... columns) throws SQLException { + assertTrue(hasIndex(table, true, columns), + "Missing unique index on " + table + "(" + String.join(", ", columns) + ")"); + } + + private void assertIndex(String table, String... columns) throws SQLException { + assertTrue(hasIndex(table, false, columns), + "Missing index on " + table + "(" + String.join(", ", columns) + ")"); + } + + private boolean hasIndex(String table, boolean unique, String... expectedColumns) throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualTable = findTable(metadata, table); + if (actualTable == null) { + return false; + } + Map> indexes = new LinkedHashMap<>(); + try (ResultSet resultSet = metadata.getIndexInfo(null, null, actualTable, false, false)) { + while (resultSet.next()) { + String indexName = resultSet.getString("INDEX_NAME"); + if (indexName == null || resultSet.getBoolean("NON_UNIQUE") == unique) { + continue; + } + indexes.computeIfAbsent(indexName, ignored -> new TreeMap<>()) + .put(resultSet.getShort("ORDINAL_POSITION"), + resultSet.getString("COLUMN_NAME").toLowerCase()); + } + } + List expected = new ArrayList<>(); + for (String column : expectedColumns) { + expected.add(column.toLowerCase()); + } + return indexes.values().stream() + .map(TreeMap::values) + .map(values -> new ArrayList<>(values)) + .anyMatch(expected::equals); + } + } + + private void assertForeignKey(String childTable, String childColumn, + String parentTable, String parentColumn) throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualChildTable = findTable(metadata, childTable); + String actualParentTable = findTable(metadata, parentTable); + if (actualChildTable == null || actualParentTable == null) { + throw new AssertionError("Missing table for foreign key " + childTable + "." + childColumn); + } + try (ResultSet resultSet = metadata.getImportedKeys(null, null, actualChildTable)) { + while (resultSet.next()) { + if (actualParentTable.equalsIgnoreCase(resultSet.getString("PKTABLE_NAME")) + && childColumn.equalsIgnoreCase(resultSet.getString("FKCOLUMN_NAME")) + && parentColumn.equalsIgnoreCase(resultSet.getString("PKCOLUMN_NAME"))) { + return; + } + } + } + } + throw new AssertionError("Missing foreign key " + childTable + "." + childColumn + + " -> " + parentTable + "." + parentColumn); + } + + private String findTable(DatabaseMetaData metadata, String expected) throws SQLException { + try (ResultSet resultSet = metadata.getTables(null, null, null, new String[]{"TABLE"})) { + while (resultSet.next()) { + String actual = resultSet.getString("TABLE_NAME"); + if (expected.equalsIgnoreCase(actual)) { + return actual; + } + } + } + return null; + } + + private Long insertAndReturnId(String sql, Object... args) { + jdbcTemplate.update(sql, args); + String table = extractTable(sql); + return jdbcTemplate.queryForObject("SELECT MAX(id) FROM " + table, Long.class); + } + + private String extractTable(String sql) { + String upper = sql.toUpperCase(); + int fromIndex = upper.indexOf(" INTO "); + int openIndex = sql.indexOf('(', fromIndex); + return sql.substring(fromIndex + " INTO ".length(), openIndex).trim(); + } + + private void assertConstraintFailure(String sql, Object... args) { + assertThrows(DataAccessException.class, () -> jdbcTemplate.update(sql, args)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyRepositoryTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyRepositoryTest.java new file mode 100644 index 0000000..40f4103 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyRepositoryTest.java @@ -0,0 +1,294 @@ +package com.mydreamjob.skill; + +import com.mydreamjob.skill.domain.OccupationSkill; +import com.mydreamjob.skill.domain.Skill; +import com.mydreamjob.skill.domain.SkillAlias; +import com.mydreamjob.skill.dto.SkillCloudSkillProjection; +import com.mydreamjob.skill.repository.OccupationSkillRepository; +import com.mydreamjob.skill.repository.SkillAliasRepository; +import com.mydreamjob.skill.repository.SkillCategoryRepository; +import com.mydreamjob.skill.repository.SkillIndustryRepository; +import com.mydreamjob.skill.repository.SkillOccupationRepository; +import com.mydreamjob.skill.repository.SkillRepository; +import com.mydreamjob.skill.repository.SkillSourceVersionRepository; +import jakarta.persistence.EntityManagerFactory; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; +import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.hibernate.boot.model.relational.Namespace; +import org.hibernate.boot.model.relational.Sequence; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.persister.entity.EntityPersister; +import org.hibernate.mapping.Table; +import org.hibernate.tool.schema.spi.SchemaFilter; +import org.hibernate.tool.schema.spi.SchemaFilterProvider; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:integrationdb_skill_repository;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "spring.jpa.hibernate.ddl-auto=validate", + "spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=com.mydreamjob.skill.SkillTaxonomyRepositoryTest$SkillTaxonomySchemaFilterProvider" +}) +@ActiveProfiles("integration-test") +class SkillTaxonomyRepositoryTest { + + @Autowired + private SkillSourceVersionRepository sourceVersionRepository; + + @Autowired + private SkillIndustryRepository industryRepository; + + @Autowired + private SkillOccupationRepository occupationRepository; + + @Autowired + private SkillCategoryRepository categoryRepository; + + @Autowired + private SkillRepository skillRepository; + + @Autowired + private SkillAliasRepository skillAliasRepository; + + @Autowired + private OccupationSkillRepository occupationSkillRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private EntityManagerFactory entityManagerFactory; + + @MockBean + private RedisTemplate redisTemplate; + + @Test + void entityMappingsAreValidatedAndSeedCountsMatchV36() { + assertThat(sourceVersionRepository.count()).isEqualTo(2L); + assertThat(industryRepository.count()).isEqualTo(3L); + assertThat(occupationRepository.count()).isEqualTo(10L); + assertThat(categoryRepository.count()).isEqualTo(68L); + assertThat(skillRepository.count()).isEqualTo(72L); + assertThat(skillAliasRepository.count()).isEqualTo(72L); + assertThat(occupationSkillRepository.count()).isEqualTo(139L); + } + + @Test + void associationChecksAcceptOnlyConsistentTaxonomyPaths() { + assertThat(industryRepository.existsById(1L)).isTrue(); + assertThat(occupationRepository.existsByIdAndIndustryId(1L, 1L)).isTrue(); + assertThat(categoryRepository.existsByIdAndOccupationId(101L, 1L)).isTrue(); + + assertThat(occupationRepository.existsByIdAndIndustryId(1L, 2L)).isFalse(); + assertThat(categoryRepository.existsByIdAndOccupationId(101L, 2L)).isFalse(); + assertThat(occupationRepository.existsByIdAndIndustryId(999L, 1L)).isFalse(); + assertThat(categoryRepository.existsByIdAndOccupationId(999L, 1L)).isFalse(); + } + + @Test + void projectionUsesWholeOccupationWhenCategoryMissingAndFiltersWhenProvided() { + List occupationSkills = + occupationSkillRepository.findSkillProjectionsByOccupationId(1L); + + assertThat(occupationSkills).hasSize(15); + assertThat(occupationSkills) + .extracting(SkillCloudSkillProjection::skillId) + .containsExactlyInAnyOrder( + 3L, 4L, 7L, 8L, 26L, 27L, 28L, 35L, + 54L, 55L, 38L, 39L, 32L, 24L, 50L); + + List languageSkills = + occupationSkillRepository.findSkillProjectionsByOccupationIdAndCategoryId(1L, 101L); + + assertThat(languageSkills).hasSize(2); + assertThat(languageSkills) + .extracting(SkillCloudSkillProjection::skillId) + .containsExactlyInAnyOrder(3L, 4L); + assertThat(languageSkills) + .extracting(SkillCloudSkillProjection::categoryId) + .containsOnly(101L); + + assertThat(occupationSkillRepository + .findSkillProjectionsByOccupationIdAndCategoryId(2L, 101L)) + .isEmpty(); + } + + @Test + @Transactional + void projectionQueriesRejectCrossOccupationCategoryMismatch() { + jdbcTemplate.update(""" + INSERT INTO occupation_skills ( + occupation_id, skill_id, category_id, importance, + importance_source_code, importance_element_id, + importance_is_proxy, relationship_type, source_version_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + 1L, 1L, 201L, new BigDecimal("4.61"), + "15-1252.00", "4.A.3.b.1", Boolean.TRUE, "ESSENTIAL", 2L); + + List occupationOneSkills = + occupationSkillRepository.findSkillProjectionsByOccupationId(1L); + + assertThat(occupationOneSkills) + .extracting(SkillCloudSkillProjection::skillId) + .contains(3L) + .doesNotContain(1L); + + assertThat(occupationSkillRepository + .findSkillProjectionsByOccupationIdAndCategoryId(1L, 201L)) + .isEmpty(); + + assertThat(occupationSkillRepository + .findSkillProjectionsByOccupationIdAndCategoryId(2L, 201L)) + .extracting(SkillCloudSkillProjection::skillId) + .contains(1L); + } + + @Test + void projectionCarriesOnetProvenanceNullImportanceAndSortOrders() { + SkillCloudSkillProjection react = occupationSkillRepository + .findSkillProjectionsByOccupationIdAndCategoryId(1L, 103L) + .stream() + .filter(item -> item.skillId().equals(26L)) + .findFirst() + .orElseThrow(); + + assertThat(react.skillCode()).isEqualTo("REACT"); + assertThat(react.nameZh()).isEqualTo("React"); + assertThat(react.nameEn()).isEqualTo("React"); + assertThat(react.sourceType()).isEqualTo("O*NET"); + assertThat(react.sourceCode()).isEqualTo("15-1254.00|React|2.E.7.h"); + assertThat(react.categoryName()).isEqualTo("前端框架"); + assertThat(react.importance()).isEqualByComparingTo(new BigDecimal("4.88")); + assertThat(react.importanceSourceCode()).isEqualTo("15-1254.00"); + assertThat(react.importanceElementId()).isEqualTo("4.A.3.b.1"); + assertThat(react.importanceIsProxy()).isTrue(); + assertThat(react.skillSortOrder()).isEqualTo(26); + assertThat(react.categorySortOrder()).isEqualTo(3); + + SkillCloudSkillProjection figma = occupationSkillRepository + .findSkillProjectionsByOccupationIdAndCategoryId(1L, 104L) + .stream() + .filter(item -> item.skillId().equals(50L)) + .findFirst() + .orElseThrow(); + + assertThat(figma.sourceType()).isEqualTo("O*NET"); + assertThat(figma.sourceCode()).isEqualTo("15-1254.00|Figma|2.E.7.d"); + assertThat(figma.importance()).isNull(); + assertThat(figma.importanceSourceCode()).isNull(); + assertThat(figma.importanceElementId()).isNull(); + assertThat(figma.importanceIsProxy()).isFalse(); + } + + @Test + void aliasAndSkillLookupsReturnOnlyRequestedRecords() { + Optional normalizedAlias = skillAliasRepository.findByNormalizedAlias("react"); + assertThat(normalizedAlias).isPresent(); + assertThat(normalizedAlias.orElseThrow().getSkillId()).isEqualTo(26L); + + List aliases = skillAliasRepository + .findAllBySkillIdInOrderByIdAsc(List.of(3L, 26L)); + assertThat(aliases).hasSize(2); + assertThat(aliases) + .extracting(SkillAlias::getSkillId) + .containsExactly(3L, 26L); + + Optional react = skillRepository.findByCode("REACT"); + assertThat(react).isPresent(); + assertThat(react.orElseThrow().getSourceType()).isEqualTo("O*NET"); + + List skills = skillRepository.findAllByIdInOrderByIdAsc(List.of(4L, 3L)); + assertThat(skills) + .extracting(Skill::getId) + .containsExactly(3L, 4L); + } + + @Test + void importanceIsProxyUsesDefaultBooleanMapping() throws NoSuchFieldException { + SessionFactoryImplementor sessionFactory = entityManagerFactory.unwrap(SessionFactoryImplementor.class); + EntityPersister persister = sessionFactory.getMappingMetamodel().getEntityDescriptor(OccupationSkill.class); + + org.hibernate.type.Type propertyType = persister.getPropertyType("importanceIsProxy"); + assertThat(propertyType.getReturnedClass()).isEqualTo(Boolean.class); + + JdbcTypeCode explicitJdbcType = OccupationSkill.class + .getDeclaredField("importanceIsProxy") + .getAnnotation(JdbcTypeCode.class); + assertThat(explicitJdbcType) + .as("importanceIsProxy must use the default Boolean mapping without a forced JDBC type") + .isNull(); + } + + /** + * The legacy H2 schema has TINYINT/Boolean mismatches outside the V35 taxonomy. + * Scope validation to the seven T4 tables without changing historical migrations or entities. + */ + public static class SkillTaxonomySchemaFilterProvider implements SchemaFilterProvider { + + private static final Set VALIDATED_TABLES = Set.of( + "skill_source_versions", + "skill_industries", + "skill_occupations", + "skill_categories", + "skills", + "skill_aliases", + "occupation_skills" + ); + + @Override + public SchemaFilter getCreateFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getDropFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getTruncatorFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getMigrateFilter() { + return SchemaFilter.ALL; + } + + @Override + public SchemaFilter getValidateFilter() { + return new SchemaFilter() { + @Override + public boolean includeNamespace(Namespace namespace) { + return true; + } + + @Override + public boolean includeTable(Table table) { + return VALIDATED_TABLES.contains(table.getName().toLowerCase(Locale.ROOT)); + } + + @Override + public boolean includeSequence(Sequence sequence) { + return false; + } + }; + } + } +} diff --git a/mydreamjob-server/src/test/resources/skill-cloud/onet-31-reference.csv b/mydreamjob-server/src/test/resources/skill-cloud/onet-31-reference.csv new file mode 100644 index 0000000..2a4e1b2 --- /dev/null +++ b/mydreamjob-server/src/test/resources/skill-cloud/onet-31-reference.csv @@ -0,0 +1,93 @@ +evidence_type,source_soc,element_id,data_value,workplace_example,source_file +SOFTWARE,11-1011.00,2.E.5.b,,Adobe Acrobat,software_skills.csv +SOFTWARE,11-1021.00,2.E.6.f,,Amazon Web Services AWS software,software_skills.csv +SOFTWARE,11-3021.00,2.E.11.a,,Nagios,software_skills.csv +SOFTWARE,13-1199.07,2.E.14.d,,Nmap,software_skills.csv +SOFTWARE,15-1241.01,2.E.11.a,,Wireshark,software_skills.csv +SOFTWARE,15-1252.00,2.E.13.c,,Bash,software_skills.csv +SOFTWARE,15-1252.00,2.E.7.e,,C++,software_skills.csv +SOFTWARE,15-1252.00,2.E.7.h,,Django,software_skills.csv +SOFTWARE,15-1252.00,2.E.10.a,,Docker,software_skills.csv +SOFTWARE,15-1252.00,2.E.5.c,,Git,software_skills.csv +SOFTWARE,15-1252.00,2.E.7.b,,Go,software_skills.csv +SOFTWARE,15-1252.00,2.E.7.e,,Gradle,software_skills.csv +SOFTWARE,15-1252.00,2.E.10.a,,Kubernetes,software_skills.csv +SOFTWARE,15-1252.00,2.E.6.d,,MongoDB,software_skills.csv +SOFTWARE,15-1252.00,2.E.6.d,,Redis,software_skills.csv +SOFTWARE,15-1252.00,2.E.7.f,,SonarQube,software_skills.csv +SOFTWARE,15-1252.00,2.E.10.a,,Spring Boot,software_skills.csv +SOFTWARE,15-1253.00,2.E.7.e,,Apache JMeter,software_skills.csv +SOFTWARE,15-1253.00,2.E.7.f,,JUnit,software_skills.csv +SOFTWARE,15-1253.00,2.E.13.c,,Postman,software_skills.csv +SOFTWARE,15-1253.00,2.E.7.f,,Selenium,software_skills.csv +SOFTWARE,15-1254.00,2.E.7.d,,Figma,software_skills.csv +SOFTWARE,15-1254.00,2.E.7.h,,Node.js,software_skills.csv +SOFTWARE,15-1254.00,2.E.7.h,,React,software_skills.csv +SOFTWARE,15-1254.00,2.E.7.h,,Vue.js,software_skills.csv +SOFTWARE,15-1255.00,2.E.4.g,,Axure RP,software_skills.csv +SOFTWARE,15-1255.00,2.E.7.a,,Chef,software_skills.csv +SOFTWARE,15-1299.07,2.E.7.b,,Apache Kafka,software_skills.csv +SOFTWARE,15-1299.08,2.E.10.a,,GitLab,software_skills.csv +SOFTWARE,15-2051.00,2.E.6.m,,Apache Spark,software_skills.csv +SOFTWARE,15-2051.00,2.E.7.b,,Flask,software_skills.csv +SOFTWARE,15-2051.00,2.E.4.i,,Microsoft Excel,software_skills.csv +SOFTWARE,15-2051.00,2.E.6.m,,Microsoft Power BI,software_skills.csv +SOFTWARE,15-2051.00,2.E.6.f,,NumPy,software_skills.csv +SOFTWARE,15-2051.00,2.E.6.f,,PyTorch,software_skills.csv +SOFTWARE,15-2051.00,2.E.7.b,,Scikit-learn,software_skills.csv +SOFTWARE,15-2051.00,2.E.6.m,,Tableau,software_skills.csv +SOFTWARE,15-2051.00,2.E.9.d,,TensorFlow,software_skills.csv +SOFTWARE,15-2051.00,2.E.6.f,,pandas,software_skills.csv +SOFTWARE,17-2051.02,2.E.10.e,,Google Chrome,software_skills.csv +SOFTWARE,17-2071.00,2.E.7.f,,Debugging software,software_skills.csv +SOFTWARE,19-3092.00,2.E.4.b,,Adobe Photoshop,software_skills.csv +SOFTWARE,25-2022.00,2.E.8.b,,Moodle,software_skills.csv +SOFTWARE,25-9031.00,2.E.8.b,,Articulate Storyline,software_skills.csv +SOFTWARE,27-4012.00,2.E.4.f,,Microsoft PowerPoint,software_skills.csv +SOFTWARE,41-3031.00,2.E.2.d,,Bloomberg Professional,software_skills.csv +SOFTWARE,41-4011.00,2.E.7.b,,Microsoft Azure software,software_skills.csv +IMPORTANCE,13-1082.00,2.C.6,2.52,,knowledge.csv +IMPORTANCE,13-1082.00,4.A.2.a.4,3.94,,work_activities.csv +IMPORTANCE,13-1082.00,4.A.2.b.4,3.73,,work_activities.csv +IMPORTANCE,13-1082.00,4.A.3.b.1,4.45,,work_activities.csv +IMPORTANCE,13-1082.00,4.A.4.a.2,4.73,,work_activities.csv +IMPORTANCE,13-2031.00,2.C.1.c,4.42,,knowledge.csv +IMPORTANCE,13-2031.00,2.C.4.a,4.03,,knowledge.csv +IMPORTANCE,13-2031.00,4.A.2.a.2,4.44,,work_activities.csv +IMPORTANCE,13-2031.00,4.A.2.a.4,4.01,,work_activities.csv +IMPORTANCE,13-2031.00,4.A.3.b.1,4.48,,work_activities.csv +IMPORTANCE,13-2053.00,2.C.4.a,3.27,,knowledge.csv +IMPORTANCE,13-2053.00,4.A.2.a.2,4.13,,work_activities.csv +IMPORTANCE,13-2053.00,4.A.2.a.3,3.90,,work_activities.csv +IMPORTANCE,13-2053.00,4.A.2.a.4,4.43,,work_activities.csv +IMPORTANCE,13-2053.00,4.A.3.b.1,4.41,,work_activities.csv +IMPORTANCE,13-2099.01,2.C.1.c,4.20,,knowledge.csv +IMPORTANCE,13-2099.01,4.A.1.b.3,3.53,,work_activities.csv +IMPORTANCE,13-2099.01,4.A.2.a.2,4.50,,work_activities.csv +IMPORTANCE,13-2099.01,4.A.2.a.3,3.05,,work_activities.csv +IMPORTANCE,13-2099.01,4.A.3.b.1,4.68,,work_activities.csv +IMPORTANCE,15-1244.00,2.C.8.a,2.34,,knowledge.csv +IMPORTANCE,15-1244.00,4.A.1.a.2,3.60,,work_activities.csv +IMPORTANCE,15-1244.00,4.A.2.a.2,4.03,,work_activities.csv +IMPORTANCE,15-1244.00,4.A.3.b.1,4.83,,work_activities.csv +IMPORTANCE,15-1252.00,2.C.8.a,2.57,,knowledge.csv +IMPORTANCE,15-1252.00,4.A.2.a.2,4.38,,work_activities.csv +IMPORTANCE,15-1252.00,4.A.2.a.3,3.94,,work_activities.csv +IMPORTANCE,15-1252.00,4.A.3.b.1,4.61,,work_activities.csv +IMPORTANCE,15-1253.00,4.A.1.b.3,2.94,,work_activities.csv +IMPORTANCE,15-1253.00,4.A.2.a.2,4.38,,work_activities.csv +IMPORTANCE,15-1253.00,4.A.2.a.3,4.24,,work_activities.csv +IMPORTANCE,15-1253.00,4.A.3.b.1,4.94,,work_activities.csv +IMPORTANCE,15-1254.00,4.A.2.a.3,3.33,,work_activities.csv +IMPORTANCE,15-1254.00,4.A.3.b.1,4.88,,work_activities.csv +IMPORTANCE,15-1254.00,4.A.4.a.2,3.88,,work_activities.csv +IMPORTANCE,15-2051.00,2.C.4.a,4.52,,knowledge.csv +IMPORTANCE,15-2051.00,2.C.6,2.28,,knowledge.csv +IMPORTANCE,15-2051.00,4.A.2.a.2,4.71,,work_activities.csv +IMPORTANCE,15-2051.00,4.A.2.a.4,4.83,,work_activities.csv +IMPORTANCE,15-2051.00,4.A.3.b.1,4.75,,work_activities.csv +IMPORTANCE,25-9031.00,2.C.6,4.78,,knowledge.csv +IMPORTANCE,25-9031.00,4.A.2.a.3,3.83,,work_activities.csv +IMPORTANCE,25-9031.00,4.A.2.a.4,4.19,,work_activities.csv +IMPORTANCE,25-9031.00,4.A.2.b.2,3.72,,work_activities.csv +IMPORTANCE,25-9031.00,4.A.3.b.1,4.45,,work_activities.csv diff --git a/mydreamjob-server/src/test/resources/skill-cloud/skill-cloud-expected-mappings.csv b/mydreamjob-server/src/test/resources/skill-cloud/skill-cloud-expected-mappings.csv new file mode 100644 index 0000000..81238cc --- /dev/null +++ b/mydreamjob-server/src/test/resources/skill-cloud/skill-cloud-expected-mappings.csv @@ -0,0 +1,222 @@ +record_type,entity_key,expected_source_type,expected_source_code,expected_element_id,expected_importance,expected_is_proxy +OCCUPATION_SOURCE,FRONTEND_DEVELOPER,ESCO,http://data.europa.eu/esco/occupation/c40a2919-48a9-40ea-b506-1f34f693496d,,, +OCCUPATION_SOURCE,BACKEND_DEVELOPER,ESCO,http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1,,, +OCCUPATION_SOURCE,QA_ENGINEER,ESCO,http://data.europa.eu/esco/occupation/106f79e4-6264-45f1-9e7a-297435cd684b,,, +OCCUPATION_SOURCE,DEVOPS_ENGINEER,ESCO,http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1,,, +OCCUPATION_SOURCE,FINANCIAL_DATA_ANALYST,ESCO,http://data.europa.eu/esco/occupation/8d586ee9-0ab1-4155-a3b0-2ca786c8e48c,,, +OCCUPATION_SOURCE,RISK_MODELING_ENGINEER,ESCO,http://data.europa.eu/esco/occupation/b09acdda-6680-49d1-a411-129610ee3bc0,,, +OCCUPATION_SOURCE,QUANTITATIVE_DEVELOPER,ESCO,http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1,,, +OCCUPATION_SOURCE,EDUCATION_PRODUCT_MANAGER,ESCO,http://data.europa.eu/esco/occupation/a79170ad-0b7b-4697-864f-1748359aa10a,,, +OCCUPATION_SOURCE,INSTRUCTIONAL_DESIGNER,ESCO,http://data.europa.eu/esco/occupation/a9c30651-ccbc-4a80-900c-7880615cdf6e,,, +OCCUPATION_SOURCE,EDUCATION_DATA_ANALYST,ESCO,http://data.europa.eu/esco/occupation/d3edb8f8-3a06-47a0-8fb9-9b212c006aa2,,, +SKILL_SOURCE,JAVA,ESCO,http://data.europa.eu/esco/skill/19a8293b-8e95-4de3-983f-77484079c389,,, +SKILL_SOURCE,PYTHON,ESCO,http://data.europa.eu/esco/skill/ccd0a1d9-afda-43d9-b901-96344886e14d,,, +SKILL_SOURCE,JAVASCRIPT,ESCO,http://data.europa.eu/esco/skill/3cd569a2-4f88-4c1e-9995-8dce8c5e51a7,,, +SKILL_SOURCE,TYPESCRIPT,ESCO,http://data.europa.eu/esco/skill/867137fb-ff1b-4ca3-99f3-cb6969aa2c68,,, +SKILL_SOURCE,SQL,ESCO,http://data.europa.eu/esco/skill/598de5b0-5b58-4ea7-8058-a4bc4d18c742,,, +SKILL_SOURCE,CSHARP,ESCO,http://data.europa.eu/esco/skill/4c016b68-4116-468c-9dc6-42710c239e4a,,, +SKILL_SOURCE,HTML,ESCO,http://data.europa.eu/esco/skill/0af062de-eb43-41e9-9b96-249e2cd22d26,,, +SKILL_SOURCE,CSS,ESCO,http://data.europa.eu/esco/skill/e5d1f825-60ed-4bdd-872a-e748c387f777,,, +SKILL_SOURCE,R,ESCO,http://data.europa.eu/esco/skill/51586df8-1c46-4b47-8583-773cb63bf00b,,, +SKILL_SOURCE,MYSQL,ESCO,http://data.europa.eu/esco/skill/4da171e5-779c-4983-a76f-91c16751e99f,,, +SKILL_SOURCE,POSTGRESQL,ESCO,http://data.europa.eu/esco/skill/a8d07b5a-c1a1-42c6-9d53-db9c7a2ca996,,, +SKILL_SOURCE,STATISTICS,ESCO,http://data.europa.eu/esco/skill/7ee4c2ea-b349-4bd2-81a3-ec31475d4833,,, +SKILL_SOURCE,MACHINE_LEARNING,ESCO,http://data.europa.eu/esco/skill/8369c2d6-c100-4cf6-bd83-9668d8678433,,, +SKILL_SOURCE,FINANCIAL_ANALYSIS,ESCO,http://data.europa.eu/esco/skill/99571e68-801f-49af-a897-5f75996642e1,,, +SKILL_SOURCE,RISK_MANAGEMENT,ESCO,http://data.europa.eu/esco/skill/6eff134b-e34f-4d6e-a6e8-5e47cf2228d0,,, +SKILL_SOURCE,INSTRUCTIONAL_DESIGN,ESCO,http://data.europa.eu/esco/skill/e3145bd7-7f35-44ec-a5dc-4417c5c9ab9a,,, +SKILL_SOURCE,CURRICULUM_DEVELOPMENT,ESCO,http://data.europa.eu/esco/skill/142a5319-60cb-42a2-891f-90ba92af18b1,,, +SKILL_SOURCE,LEARNING_MANAGEMENT_SYSTEMS,ESCO,http://data.europa.eu/esco/skill/6c80d53c-d8c9-41fe-998f-091fca208834,,, +SKILL_SOURCE,EDUCATIONAL_TECHNOLOGY,ESCO,http://data.europa.eu/esco/skill/8a6394d1-f44f-4b0d-81d5-7201fc025ba8,,, +SKILL_SOURCE,COMMUNICATION,ESCO,http://data.europa.eu/esco/skill/15d76317-c71a-4fa2-aadc-2ecc34e627b7,,, +SKILL_SOURCE,CRITICAL_THINKING,ESCO,http://data.europa.eu/esco/skill/a9e6718b-b9d8-44c2-b06e-85ceed9d144c,,, +SKILL_SOURCE,COLLABORATION,ESCO,http://data.europa.eu/esco/skill/082967d4-b94c-4305-aa13-35a6aa8c5c7c,,, +SKILL_SOURCE,PROJECT_MANAGEMENT,ESCO,http://data.europa.eu/esco/skill/7111b95d-0ce3-441a-9d92-4c75d05c4388,,, +SKILL_SOURCE,AGILE,ESCO,http://data.europa.eu/esco/skill/dba46f87-0831-49cd-a1c7-340a653c0221,,, +SKILL_SOURCE,JENKINS,ESCO,http://data.europa.eu/esco/skill/f47a1998-0beb-43be-9f46-380aa4d183da,,, +SKILL_SOURCE,REACT,O*NET,15-1254.00|React|2.E.7.h,,, +SKILL_SOURCE,VUE_JS,O*NET,15-1254.00|Vue.js|2.E.7.h,,, +SKILL_SOURCE,NODE_JS,O*NET,15-1254.00|Node.js|2.E.7.h,,, +SKILL_SOURCE,SPRING_BOOT,O*NET,15-1252.00|Spring Boot|2.E.10.a,,, +SKILL_SOURCE,DJANGO,O*NET,15-1252.00|Django|2.E.7.h,,, +SKILL_SOURCE,FLASK,O*NET,15-2051.00|Flask|2.E.7.b,,, +SKILL_SOURCE,GIT,O*NET,15-1252.00|Git|2.E.5.c,,, +SKILL_SOURCE,DOCKER,O*NET,15-1252.00|Docker|2.E.10.a,,, +SKILL_SOURCE,KUBERNETES,O*NET,15-1252.00|Kubernetes|2.E.10.a,,, +SKILL_SOURCE,GRADLE,O*NET,15-1252.00|Gradle|2.E.7.e,,, +SKILL_SOURCE,MONGODB,O*NET,15-1252.00|MongoDB|2.E.6.d,,, +SKILL_SOURCE,REDIS,O*NET,15-1252.00|Redis|2.E.6.d,,, +SKILL_SOURCE,SELENIUM,O*NET,15-1253.00|Selenium|2.E.7.f,,, +SKILL_SOURCE,JUNIT,O*NET,15-1253.00|JUnit|2.E.7.f,,, +SKILL_SOURCE,POSTMAN,O*NET,15-1253.00|Postman|2.E.13.c,,, +SKILL_SOURCE,PANDAS,O*NET,15-2051.00|pandas|2.E.6.f,,, +SKILL_SOURCE,NUMPY,O*NET,15-2051.00|NumPy|2.E.6.f,,, +SKILL_SOURCE,TABLEAU,O*NET,15-2051.00|Tableau|2.E.6.m,,, +SKILL_SOURCE,POWER_BI,O*NET,15-2051.00|Microsoft Power BI|2.E.6.m,,, +SKILL_SOURCE,MICROSOFT_EXCEL,O*NET,15-2051.00|Microsoft Excel|2.E.4.i,,, +SKILL_SOURCE,TENSORFLOW,O*NET,15-2051.00|TensorFlow|2.E.9.d,,, +SKILL_SOURCE,PYTORCH,O*NET,15-2051.00|PyTorch|2.E.6.f,,, +SKILL_SOURCE,SCIKIT_LEARN,O*NET,15-2051.00|Scikit-learn|2.E.7.b,,, +SKILL_SOURCE,APACHE_SPARK,O*NET,15-2051.00|Apache Spark|2.E.6.m,,, +SKILL_SOURCE,FIGMA,O*NET,15-1254.00|Figma|2.E.7.d,,, +SKILL_SOURCE,BASH,O*NET,15-1252.00|Bash|2.E.13.c,,, +SKILL_SOURCE,GO,O*NET,15-1252.00|Go|2.E.7.b,,, +SKILL_SOURCE,CPP,O*NET,15-1252.00|C++|2.E.7.e,,, +SKILL_SOURCE,GOOGLE_CHROME,O*NET,17-2051.02|Google Chrome|2.E.10.e,,, +SKILL_SOURCE,DEBUGGING_SOFTWARE,O*NET,17-2071.00|Debugging software|2.E.7.f,,, +SKILL_SOURCE,APACHE_KAFKA,O*NET,15-1299.07|Apache Kafka|2.E.7.b,,, +SKILL_SOURCE,SONARQUBE,O*NET,15-1252.00|SonarQube|2.E.7.f,,, +SKILL_SOURCE,CHEF,O*NET,15-1255.00|Chef|2.E.7.a,,, +SKILL_SOURCE,GITLAB,O*NET,15-1299.08|GitLab|2.E.10.a,,, +SKILL_SOURCE,NAGIOS,O*NET,11-3021.00|Nagios|2.E.11.a,,, +SKILL_SOURCE,NMAP,O*NET,13-1199.07|Nmap|2.E.14.d,,, +SKILL_SOURCE,BLOOMBERG_PROFESSIONAL,O*NET,41-3031.00|Bloomberg Professional|2.E.2.d,,, +SKILL_SOURCE,MOODLE,O*NET,25-2022.00|Moodle|2.E.8.b,,, +SKILL_SOURCE,ARTICULATE_STORYLINE,O*NET,25-9031.00|Articulate Storyline|2.E.8.b,,, +SKILL_SOURCE,MICROSOFT_POWERPOINT,O*NET,27-4012.00|Microsoft PowerPoint|2.E.4.f,,, +SKILL_SOURCE,AXURE_RP,O*NET,15-1255.00|Axure RP|2.E.4.g,,, +SKILL_SOURCE,ADOBE_PHOTOSHOP,O*NET,19-3092.00|Adobe Photoshop|2.E.4.b,,, +SKILL_SOURCE,APACHE_JMETER,O*NET,15-1253.00|Apache JMeter|2.E.7.e,,, +SKILL_SOURCE,ADOBE_ACROBAT,O*NET,11-1011.00|Adobe Acrobat|2.E.5.b,,, +SKILL_SOURCE,MICROSOFT_AZURE,O*NET,41-4011.00|Microsoft Azure software|2.E.7.b,,, +SKILL_SOURCE,AWS,O*NET,11-1021.00|Amazon Web Services AWS software|2.E.6.f,,, +SKILL_SOURCE,WIRESHARK,O*NET,15-1241.01|Wireshark|2.E.11.a,,, +RELATION,FRONTEND_DEVELOPER|JAVASCRIPT,O*NET,15-1254.00,4.A.3.b.1,4.88,1 +RELATION,FRONTEND_DEVELOPER|TYPESCRIPT,O*NET,15-1254.00,4.A.3.b.1,4.88,1 +RELATION,FRONTEND_DEVELOPER|HTML,O*NET,15-1254.00,4.A.3.b.1,4.88,1 +RELATION,FRONTEND_DEVELOPER|CSS,O*NET,15-1254.00,4.A.3.b.1,4.88,1 +RELATION,FRONTEND_DEVELOPER|REACT,O*NET,15-1254.00,4.A.3.b.1,4.88,1 +RELATION,FRONTEND_DEVELOPER|VUE_JS,O*NET,15-1254.00,4.A.3.b.1,4.88,1 +RELATION,FRONTEND_DEVELOPER|NODE_JS,O*NET,15-1254.00,4.A.3.b.1,4.88,1 +RELATION,FRONTEND_DEVELOPER|GRADLE,O*NET,15-1254.00,4.A.3.b.1,4.88,1 +RELATION,FRONTEND_DEVELOPER|GOOGLE_CHROME,O*NET,15-1254.00,4.A.3.b.1,4.88,1 +RELATION,FRONTEND_DEVELOPER|DEBUGGING_SOFTWARE,O*NET,15-1254.00,4.A.3.b.1,4.88,1 +RELATION,FRONTEND_DEVELOPER|SELENIUM,O*NET,15-1254.00,4.A.2.a.3,3.33,1 +RELATION,FRONTEND_DEVELOPER|JUNIT,O*NET,15-1254.00,4.A.2.a.3,3.33,1 +RELATION,FRONTEND_DEVELOPER|GIT,O*NET,15-1254.00,4.A.4.a.2,3.88,1 +RELATION,FRONTEND_DEVELOPER|AGILE,O*NET,15-1254.00,4.A.4.a.2,3.88,1 +RELATION,BACKEND_DEVELOPER|JAVA,O*NET,15-1252.00,4.A.3.b.1,4.61,1 +RELATION,BACKEND_DEVELOPER|PYTHON,O*NET,15-1252.00,4.A.3.b.1,4.61,1 +RELATION,BACKEND_DEVELOPER|SPRING_BOOT,O*NET,15-1252.00,4.A.3.b.1,4.61,1 +RELATION,BACKEND_DEVELOPER|DJANGO,O*NET,15-1252.00,4.A.3.b.1,4.61,1 +RELATION,BACKEND_DEVELOPER|MYSQL,O*NET,15-1252.00,4.A.2.a.2,4.38,1 +RELATION,BACKEND_DEVELOPER|POSTGRESQL,O*NET,15-1252.00,4.A.2.a.2,4.38,1 +RELATION,BACKEND_DEVELOPER|REDIS,O*NET,15-1252.00,4.A.2.a.2,4.38,1 +RELATION,BACKEND_DEVELOPER|APACHE_KAFKA,O*NET,15-1252.00,4.A.2.a.2,4.38,1 +RELATION,BACKEND_DEVELOPER|DOCKER,O*NET,15-1252.00,4.A.3.b.1,4.61,1 +RELATION,BACKEND_DEVELOPER|KUBERNETES,O*NET,15-1252.00,4.A.3.b.1,4.61,1 +RELATION,BACKEND_DEVELOPER|JUNIT,O*NET,15-1252.00,4.A.2.a.3,3.94,1 +RELATION,BACKEND_DEVELOPER|POSTMAN,O*NET,15-1252.00,4.A.2.a.3,3.94,1 +RELATION,BACKEND_DEVELOPER|GIT,O*NET,15-1252.00,4.A.3.b.1,4.61,1 +RELATION,BACKEND_DEVELOPER|GRADLE,O*NET,15-1252.00,4.A.3.b.1,4.61,1 +RELATION,BACKEND_DEVELOPER|NMAP,O*NET,15-1252.00,2.C.8.a,2.57,1 +RELATION,BACKEND_DEVELOPER|SONARQUBE,O*NET,15-1252.00,2.C.8.a,2.57,1 +RELATION,QA_ENGINEER|JAVA,O*NET,15-1253.00,4.A.3.b.1,4.94,1 +RELATION,QA_ENGINEER|PYTHON,O*NET,15-1253.00,4.A.3.b.1,4.94,1 +RELATION,QA_ENGINEER|POSTMAN,O*NET,15-1253.00,4.A.2.a.3,4.24,1 +RELATION,QA_ENGINEER|CRITICAL_THINKING,O*NET,15-1253.00,4.A.2.a.3,4.24,1 +RELATION,QA_ENGINEER|SELENIUM,O*NET,15-1253.00,4.A.3.b.1,4.94,1 +RELATION,QA_ENGINEER|JUNIT,O*NET,15-1253.00,4.A.3.b.1,4.94,1 +RELATION,QA_ENGINEER|SQL,O*NET,15-1253.00,4.A.2.a.2,4.38,1 +RELATION,QA_ENGINEER|MYSQL,O*NET,15-1253.00,4.A.2.a.2,4.38,1 +RELATION,QA_ENGINEER|APACHE_JMETER,O*NET,15-1253.00,4.A.1.b.3,2.94,1 +RELATION,QA_ENGINEER|BASH,O*NET,15-1253.00,4.A.1.b.3,2.94,1 +RELATION,QA_ENGINEER|JENKINS,O*NET,15-1253.00,4.A.3.b.1,4.94,1 +RELATION,QA_ENGINEER|GITLAB,O*NET,15-1253.00,4.A.3.b.1,4.94,1 +RELATION,DEVOPS_ENGINEER|PYTHON,O*NET,15-1244.00,4.A.3.b.1,4.83,1 +RELATION,DEVOPS_ENGINEER|BASH,O*NET,15-1244.00,4.A.3.b.1,4.83,1 +RELATION,DEVOPS_ENGINEER|AWS,O*NET,15-1244.00,4.A.3.b.1,4.83,1 +RELATION,DEVOPS_ENGINEER|MICROSOFT_AZURE,O*NET,15-1244.00,4.A.3.b.1,4.83,1 +RELATION,DEVOPS_ENGINEER|DOCKER,O*NET,15-1244.00,4.A.3.b.1,4.83,1 +RELATION,DEVOPS_ENGINEER|KUBERNETES,O*NET,15-1244.00,4.A.3.b.1,4.83,1 +RELATION,DEVOPS_ENGINEER|JENKINS,O*NET,15-1244.00,4.A.3.b.1,4.83,1 +RELATION,DEVOPS_ENGINEER|GITLAB,O*NET,15-1244.00,4.A.3.b.1,4.83,1 +RELATION,DEVOPS_ENGINEER|CHEF,O*NET,15-1244.00,4.A.3.b.1,4.83,1 +RELATION,DEVOPS_ENGINEER|GIT,O*NET,15-1244.00,4.A.3.b.1,4.83,1 +RELATION,DEVOPS_ENGINEER|NAGIOS,O*NET,15-1244.00,4.A.1.a.2,3.60,1 +RELATION,DEVOPS_ENGINEER|WIRESHARK,O*NET,15-1244.00,4.A.1.a.2,3.60,1 +RELATION,DEVOPS_ENGINEER|NMAP,O*NET,15-1244.00,2.C.8.a,2.34,1 +RELATION,DEVOPS_ENGINEER|SONARQUBE,O*NET,15-1244.00,2.C.8.a,2.34,1 +RELATION,DEVOPS_ENGINEER|MYSQL,O*NET,15-1244.00,4.A.2.a.2,4.03,1 +RELATION,DEVOPS_ENGINEER|POSTGRESQL,O*NET,15-1244.00,4.A.2.a.2,4.03,1 +RELATION,FINANCIAL_DATA_ANALYST|PYTHON,O*NET,13-2031.00,4.A.3.b.1,4.48,1 +RELATION,FINANCIAL_DATA_ANALYST|R,O*NET,13-2031.00,4.A.3.b.1,4.48,1 +RELATION,FINANCIAL_DATA_ANALYST|PANDAS,O*NET,13-2031.00,4.A.2.a.4,4.01,1 +RELATION,FINANCIAL_DATA_ANALYST|TABLEAU,O*NET,13-2031.00,4.A.2.a.4,4.01,1 +RELATION,FINANCIAL_DATA_ANALYST|SQL,O*NET,13-2031.00,4.A.2.a.2,4.44,1 +RELATION,FINANCIAL_DATA_ANALYST|POSTGRESQL,O*NET,13-2031.00,4.A.2.a.2,4.44,1 +RELATION,FINANCIAL_DATA_ANALYST|STATISTICS,O*NET,13-2031.00,2.C.4.a,4.03,1 +RELATION,FINANCIAL_DATA_ANALYST|MACHINE_LEARNING,O*NET,13-2031.00,2.C.4.a,4.03,1 +RELATION,FINANCIAL_DATA_ANALYST|BLOOMBERG_PROFESSIONAL,O*NET,13-2031.00,4.A.3.b.1,4.48,1 +RELATION,FINANCIAL_DATA_ANALYST|MICROSOFT_EXCEL,O*NET,13-2031.00,4.A.3.b.1,4.48,1 +RELATION,FINANCIAL_DATA_ANALYST|FINANCIAL_ANALYSIS,O*NET,13-2031.00,2.C.1.c,4.42,1 +RELATION,FINANCIAL_DATA_ANALYST|RISK_MANAGEMENT,O*NET,13-2031.00,2.C.1.c,4.42,1 +RELATION,FINANCIAL_DATA_ANALYST|GIT,O*NET,13-2031.00,4.A.3.b.1,4.48,1 +RELATION,FINANCIAL_DATA_ANALYST|POWER_BI,O*NET,13-2031.00,4.A.3.b.1,4.48,1 +RELATION,RISK_MODELING_ENGINEER|PYTHON,O*NET,13-2053.00,4.A.3.b.1,4.41,1 +RELATION,RISK_MODELING_ENGINEER|R,O*NET,13-2053.00,4.A.3.b.1,4.41,1 +RELATION,RISK_MODELING_ENGINEER|STATISTICS,O*NET,13-2053.00,2.C.4.a,3.27,1 +RELATION,RISK_MODELING_ENGINEER|MACHINE_LEARNING,O*NET,13-2053.00,2.C.4.a,3.27,1 +RELATION,RISK_MODELING_ENGINEER|RISK_MANAGEMENT,O*NET,13-2053.00,4.A.2.a.4,4.43,1 +RELATION,RISK_MODELING_ENGINEER|CRITICAL_THINKING,O*NET,13-2053.00,4.A.2.a.4,4.43,1 +RELATION,RISK_MODELING_ENGINEER|TENSORFLOW,O*NET,13-2053.00,4.A.3.b.1,4.41,1 +RELATION,RISK_MODELING_ENGINEER|SCIKIT_LEARN,O*NET,13-2053.00,4.A.3.b.1,4.41,1 +RELATION,RISK_MODELING_ENGINEER|SQL,O*NET,13-2053.00,4.A.2.a.2,4.13,1 +RELATION,RISK_MODELING_ENGINEER|APACHE_SPARK,O*NET,13-2053.00,4.A.2.a.2,4.13,1 +RELATION,RISK_MODELING_ENGINEER|JUNIT,O*NET,13-2053.00,4.A.2.a.3,3.90,1 +RELATION,RISK_MODELING_ENGINEER|SONARQUBE,O*NET,13-2053.00,4.A.2.a.3,3.90,1 +RELATION,RISK_MODELING_ENGINEER|DOCKER,O*NET,13-2053.00,4.A.3.b.1,4.41,1 +RELATION,RISK_MODELING_ENGINEER|GIT,O*NET,13-2053.00,4.A.3.b.1,4.41,1 +RELATION,QUANTITATIVE_DEVELOPER|PYTHON,O*NET,13-2099.01,4.A.3.b.1,4.68,1 +RELATION,QUANTITATIVE_DEVELOPER|CPP,O*NET,13-2099.01,4.A.3.b.1,4.68,1 +RELATION,QUANTITATIVE_DEVELOPER|GO,O*NET,13-2099.01,4.A.3.b.1,4.68,1 +RELATION,QUANTITATIVE_DEVELOPER|NUMPY,O*NET,13-2099.01,4.A.3.b.1,4.68,1 +RELATION,QUANTITATIVE_DEVELOPER|SCIKIT_LEARN,O*NET,13-2099.01,4.A.3.b.1,4.68,1 +RELATION,QUANTITATIVE_DEVELOPER|BLOOMBERG_PROFESSIONAL,O*NET,13-2099.01,2.C.1.c,4.20,1 +RELATION,QUANTITATIVE_DEVELOPER|FINANCIAL_ANALYSIS,O*NET,13-2099.01,2.C.1.c,4.20,1 +RELATION,QUANTITATIVE_DEVELOPER|POSTGRESQL,O*NET,13-2099.01,4.A.2.a.2,4.50,1 +RELATION,QUANTITATIVE_DEVELOPER|SQL,O*NET,13-2099.01,4.A.2.a.2,4.50,1 +RELATION,QUANTITATIVE_DEVELOPER|APACHE_SPARK,O*NET,13-2099.01,4.A.1.b.3,3.53,1 +RELATION,QUANTITATIVE_DEVELOPER|APACHE_KAFKA,O*NET,13-2099.01,4.A.1.b.3,3.53,1 +RELATION,QUANTITATIVE_DEVELOPER|JUNIT,O*NET,13-2099.01,4.A.2.a.3,3.05,1 +RELATION,QUANTITATIVE_DEVELOPER|APACHE_JMETER,O*NET,13-2099.01,4.A.2.a.3,3.05,1 +RELATION,QUANTITATIVE_DEVELOPER|DOCKER,O*NET,13-2099.01,4.A.3.b.1,4.68,1 +RELATION,QUANTITATIVE_DEVELOPER|KUBERNETES,O*NET,13-2099.01,4.A.3.b.1,4.68,1 +RELATION,EDUCATION_PRODUCT_MANAGER|INSTRUCTIONAL_DESIGN,O*NET,13-1082.00,2.C.6,2.52,1 +RELATION,EDUCATION_PRODUCT_MANAGER|CURRICULUM_DEVELOPMENT,O*NET,13-1082.00,2.C.6,2.52,1 +RELATION,EDUCATION_PRODUCT_MANAGER|AXURE_RP,O*NET,13-1082.00,4.A.2.a.4,3.94,1 +RELATION,EDUCATION_PRODUCT_MANAGER|POWER_BI,O*NET,13-1082.00,4.A.2.a.4,3.94,1 +RELATION,EDUCATION_PRODUCT_MANAGER|PROJECT_MANAGEMENT,O*NET,13-1082.00,4.A.2.b.4,3.73,1 +RELATION,EDUCATION_PRODUCT_MANAGER|AGILE,O*NET,13-1082.00,4.A.2.b.4,3.73,1 +RELATION,EDUCATION_PRODUCT_MANAGER|TABLEAU,O*NET,13-1082.00,4.A.2.a.4,3.94,1 +RELATION,EDUCATION_PRODUCT_MANAGER|PYTHON,O*NET,13-1082.00,4.A.2.a.4,3.94,1 +RELATION,EDUCATION_PRODUCT_MANAGER|LEARNING_MANAGEMENT_SYSTEMS,O*NET,13-1082.00,4.A.3.b.1,4.45,1 +RELATION,EDUCATION_PRODUCT_MANAGER|MOODLE,O*NET,13-1082.00,4.A.3.b.1,4.45,1 +RELATION,EDUCATION_PRODUCT_MANAGER|GIT,O*NET,13-1082.00,4.A.4.a.2,4.73,1 +RELATION,EDUCATION_PRODUCT_MANAGER|MICROSOFT_EXCEL,O*NET,13-1082.00,4.A.4.a.2,4.73,1 +RELATION,INSTRUCTIONAL_DESIGNER|INSTRUCTIONAL_DESIGN,O*NET,25-9031.00,2.C.6,4.78,1 +RELATION,INSTRUCTIONAL_DESIGNER|CURRICULUM_DEVELOPMENT,O*NET,25-9031.00,2.C.6,4.78,1 +RELATION,INSTRUCTIONAL_DESIGNER|LEARNING_MANAGEMENT_SYSTEMS,O*NET,25-9031.00,4.A.3.b.1,4.45,1 +RELATION,INSTRUCTIONAL_DESIGNER|MOODLE,O*NET,25-9031.00,4.A.3.b.1,4.45,1 +RELATION,INSTRUCTIONAL_DESIGNER|ARTICULATE_STORYLINE,O*NET,25-9031.00,4.A.2.b.2,3.72,1 +RELATION,INSTRUCTIONAL_DESIGNER|FIGMA,O*NET,25-9031.00,4.A.2.b.2,3.72,1 +RELATION,INSTRUCTIONAL_DESIGNER|EDUCATIONAL_TECHNOLOGY,O*NET,25-9031.00,4.A.2.a.3,3.83,1 +RELATION,INSTRUCTIONAL_DESIGNER|CRITICAL_THINKING,O*NET,25-9031.00,4.A.2.a.3,3.83,1 +RELATION,INSTRUCTIONAL_DESIGNER|STATISTICS,O*NET,25-9031.00,4.A.2.a.4,4.19,1 +RELATION,INSTRUCTIONAL_DESIGNER|MICROSOFT_EXCEL,O*NET,25-9031.00,4.A.2.a.4,4.19,1 +RELATION,INSTRUCTIONAL_DESIGNER|ADOBE_ACROBAT,O*NET,25-9031.00,4.A.2.a.3,3.83,1 +RELATION,INSTRUCTIONAL_DESIGNER|COMMUNICATION,O*NET,25-9031.00,4.A.2.a.3,3.83,1 +RELATION,EDUCATION_DATA_ANALYST|PYTHON,O*NET,15-2051.00,4.A.3.b.1,4.75,1 +RELATION,EDUCATION_DATA_ANALYST|R,O*NET,15-2051.00,4.A.3.b.1,4.75,1 +RELATION,EDUCATION_DATA_ANALYST|TABLEAU,O*NET,15-2051.00,4.A.2.a.4,4.83,1 +RELATION,EDUCATION_DATA_ANALYST|POWER_BI,O*NET,15-2051.00,4.A.2.a.4,4.83,1 +RELATION,EDUCATION_DATA_ANALYST|SQL,O*NET,15-2051.00,4.A.2.a.2,4.71,1 +RELATION,EDUCATION_DATA_ANALYST|POSTGRESQL,O*NET,15-2051.00,4.A.2.a.2,4.71,1 +RELATION,EDUCATION_DATA_ANALYST|STATISTICS,O*NET,15-2051.00,2.C.4.a,4.52,1 +RELATION,EDUCATION_DATA_ANALYST|MACHINE_LEARNING,O*NET,15-2051.00,2.C.4.a,4.52,1 +RELATION,EDUCATION_DATA_ANALYST|CURRICULUM_DEVELOPMENT,O*NET,15-2051.00,2.C.6,2.28,1 +RELATION,EDUCATION_DATA_ANALYST|EDUCATIONAL_TECHNOLOGY,O*NET,15-2051.00,2.C.6,2.28,1 +RELATION,EDUCATION_DATA_ANALYST|MICROSOFT_EXCEL,O*NET,15-2051.00,4.A.3.b.1,4.75,1 +RELATION,EDUCATION_DATA_ANALYST|MICROSOFT_POWERPOINT,O*NET,15-2051.00,4.A.3.b.1,4.75,1 +RELATION,FRONTEND_DEVELOPER|FIGMA,O*NET,,,,0 +RELATION,INSTRUCTIONAL_DESIGNER|ADOBE_PHOTOSHOP,O*NET,,,,0 diff --git a/scripts/verify_salary_calculator_blackbox.py b/scripts/verify_salary_calculator_blackbox.py new file mode 100644 index 0000000..eb690db --- /dev/null +++ b/scripts/verify_salary_calculator_blackbox.py @@ -0,0 +1,131 @@ +"""N05 salary calculator black-box verification. + +Run only against an isolated, disposable MySQL container and an app wired to it. +Required environment variables: + BASE_URL=http://127.0.0.1:/api/v1 + DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME +""" +import os +import sys +import time + +import pymysql +import requests + +BASE = os.environ.get("BASE_URL", "").rstrip("/") +DB = { + "host": os.environ["DB_HOST"], + "port": int(os.environ["DB_PORT"]), + "user": os.environ["DB_USERNAME"], + "password": os.environ["DB_PASSWORD"], + "database": os.environ["DB_NAME"], +} +if not BASE: + raise SystemExit("BASE_URL is required") + + +def db(): + return pymysql.connect(**DB, autocommit=True) + + +def sql_one(query, args=()): + with db() as conn: + with conn.cursor() as cur: + cur.execute(query, args) + return cur.fetchone() + + +def sql_all(query, args=()): + with db() as conn: + with conn.cursor() as cur: + cur.execute(query, args) + return cur.fetchall() + + +def login_student(phone): + r = requests.post(f"{BASE}/auth/sms/send", params={"phone": phone, "purpose": "login"}, timeout=10) + assert r.status_code == 200, r.text + code = None + for _ in range(20): + row = sql_one( + "SELECT code FROM sms_verification_codes " + "WHERE phone=%s AND purpose='login' AND used=0 ORDER BY id DESC LIMIT 1", + (phone,), + ) + if row: + code = row[0] + break + time.sleep(0.25) + assert code, "mock SMS code not found" + r = requests.post(f"{BASE}/auth/login", json={"phone": phone, "code": code, "purpose": "login"}, timeout=10) + assert r.status_code == 200, r.text + return r.json()["data"]["accessToken"] + + +def main(): + versions = {row[0] for row in sql_all("SELECT version FROM flyway_schema_history")} + assert "40" in versions and "41" in versions, versions + + assert sql_one("SELECT COUNT(*) FROM salary_city_policy")[0] == 359 + levels = dict(sql_all("SELECT data_level, COUNT(*) FROM salary_city_policy GROUP BY data_level")) + assert levels == {"CITY": 12, "PROVINCE_FALLBACK": 347}, levels + + phone = os.environ.get("BLACKBOX_PHONE", f"139{int(time.time()) % 100000000:08d}") + token = login_student(phone) + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + cities = requests.get(f"{BASE}/salary-calculator/cities", headers=headers, timeout=10) + assert cities.status_code == 200 and len(cities.json()["data"]) == 359, cities.text + + city = requests.get(f"{BASE}/salary-calculator/cities/110000", headers=headers, timeout=10) + assert city.status_code == 200 and city.json()["data"]["dataLevel"] == "CITY", city.text + + payload = { + "cityCode": "110000", + "grossMonthlySalary": 10000.00, + "monthIndex": 1, + "specialAdditionalDeduction": 0.00, + "expectedPolicyVersion": city.json()["data"]["policyVersion"], + "idempotencyKey": f"blackbox-{phone}", + "overrides": None, + } + first = requests.post(f"{BASE}/salary-calculator/calculations", headers=headers, json=payload, timeout=10) + assert first.status_code == 200, first.text + history_id = first.json()["data"]["historyId"] + assert float(first.json()["data"]["result"]["netSalary"]) == 7667.5, first.text + + replay = requests.post(f"{BASE}/salary-calculator/calculations", headers=headers, json=payload, timeout=10) + assert replay.status_code == 200 and replay.json()["data"]["historyId"] == history_id, replay.text + + conflict_payload = dict(payload) + conflict_payload["grossMonthlySalary"] = 12000.00 + conflict = requests.post(f"{BASE}/salary-calculator/calculations", headers=headers, json=conflict_payload, timeout=10) + assert conflict.status_code == 409 and conflict.json()["code"] == 9804, conflict.text + + stale_payload = dict(payload) + stale_payload["idempotencyKey"] = "stale-" + phone + stale_payload["expectedPolicyVersion"] = "stale-version" + stale = requests.post(f"{BASE}/salary-calculator/calculations", headers=headers, json=stale_payload, timeout=10) + assert stale.status_code == 409 and stale.json()["code"] == 9805, stale.text + assert stale.json()["data"]["latestPolicyVersion"] == city.json()["data"]["policyVersion"] + + history = requests.get(f"{BASE}/salary-calculator/calculations", headers=headers, timeout=10) + assert history.status_code == 200 and history.json()["data"]["total"] == 1, history.text + + detail = requests.get(f"{BASE}/salary-calculator/calculations/{history_id}", headers=headers, timeout=10) + assert detail.status_code == 200 and detail.json()["data"]["policySnapshot"]["dataLevel"] == "CITY", detail.text + + deleted = requests.delete(f"{BASE}/salary-calculator/calculations/{history_id}", headers=headers, timeout=10) + assert deleted.status_code == 200, deleted.text + missing = requests.get(f"{BASE}/salary-calculator/calculations/{history_id}", headers=headers, timeout=10) + assert missing.status_code == 404 and missing.json()["code"] == 9803, missing.text + + print("PASS: N05 salary calculator disposable-MySQL black box") + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print(f"FAIL: {type(exc).__name__}: {exc}", file=sys.stderr) + raise diff --git a/specs/company-knowledge/spec.md b/specs/company-knowledge/spec.md new file mode 100644 index 0000000..eab2578 --- /dev/null +++ b/specs/company-knowledge/spec.md @@ -0,0 +1,129 @@ +# Company Knowledge Specification + +## Purpose + +定义公司知识配置、学生公司知识聚合和公开评价小样本保护行为。 + +## Requirements + +### Requirement: 学生读取公司知识介绍 + +系统 SHALL 允许已登录 STUDENT 通过 `GET /onboarding/companies/{companyId}/knowledge` 读取公司知识介绍。响应 MUST 聚合企业基础资料、企业维护的知识内容以及公开评价摘要,并使用白名单 DTO 排除 `ownerUserId` 等内部字段。 + +#### Scenario: 企业已维护知识内容 + +- **WHEN** STUDENT 请求存在企业且该企业已有知识内容 +- **THEN** 系统返回企业基础资料、文化、工作制度、入职指南和更新时间 +- **AND** `knowledgeState` 为 `CONFIGURED` +- **AND** 不返回企业 `ownerUserId`、员工用户 ID、评价哈希或审核原因 + +#### Scenario: 企业未维护知识内容 + +- **WHEN** STUDENT 请求存在企业且该企业没有知识内容记录 +- **THEN** 系统仍返回企业基础资料 +- **AND** `knowledgeState` 为 `BASIC_ONLY` +- **AND** 知识内容字段为 `null` 或空集合,不返回 404 + +#### Scenario: 企业不存在 + +- **WHEN** STUDENT 请求不存在的 `companyId` +- **THEN** 系统返回 404 `COMPANY_NOT_FOUND` + +### Requirement: 公司知识评价摘要使用公开数据和小样本保护 + +系统 SHALL 聚合该企业全部岗位中状态为 `PUBLISHED` 且审核状态为 `AUTO_APPROVED` 或 `APPROVED` 的评价。公开评价少于 3 条时 MUST 返回 `INSUFFICIENT` 或 `EMPTY`,且不得返回平均分或维度均值。达到 3 条时 MAY 返回平均评分、评分分布、热门标签和八维均值。 + +#### Scenario: 无公开评价 + +- **WHEN** 企业没有任何符合条件的公开评价 +- **THEN** `reviewSummary.state` 为 `EMPTY` +- **AND** `totalReviews` 为 0 +- **AND** `averageRating` 与 `dimensionSummary` 为空 + +#### Scenario: 公开评价少于 3 条 + +- **WHEN** 企业只有 1 或 2 条符合条件的公开评价 +- **THEN** `reviewSummary.state` 为 `INSUFFICIENT` +- **AND** 系统返回真实 `totalReviews` +- **AND** 不返回平均分、评分分布或维度均值 + +#### Scenario: 公开评价达到 3 条 + +- **WHEN** 企业有至少 3 条符合条件的公开评价 +- **THEN** `reviewSummary.state` 为 `SUFFICIENT` +- **AND** 系统返回平均评分、评分分布、热门标签和维度汇总 +- **AND** 每个维度返回样本数及是否达到小样本阈值 + +### Requirement: 企业读取本企业知识配置 + +系统 SHALL 允许 COMPANY 通过 `GET /onboarding/company/knowledge` 读取本企业知识配置。无记录时 MUST 返回 `configured=false` 和空内容,而不是 404;有记录时 MUST 返回内容和当前版本。 + +#### Scenario: 尚未配置 + +- **WHEN** COMPANY 请求本企业知识配置且尚未创建记录 +- **THEN** 系统返回 `configured=false` +- **AND** 内容字段为空,版本为 `0` + +#### Scenario: 已配置 + +- **WHEN** COMPANY 请求本企业已有知识配置 +- **THEN** 系统返回文化、工作制度、入职指南、更新时间和当前版本 +- **AND** `configured=true` + +### Requirement: 企业 Upsert 公司知识内容 + +系统 SHALL 允许 COMPANY 通过 `PUT /onboarding/company/knowledge` 创建或更新本企业知识内容。字段 MUST 使用纯文本并受长度限制,不允许 HTML 富文本。更新已有记录时 MUST 使用乐观锁版本;版本冲突返回 409 `ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE`。 + +#### Scenario: 首次创建 + +- **WHEN** COMPANY 首次提交合法的知识内容 +- **THEN** 系统创建绑定该企业的知识记录 +- **AND** 返回 `configured=true` 和版本 `0` + +#### Scenario: 使用当前版本更新 + +- **WHEN** COMPANY 使用当前版本提交新的文化、制度或入职指南 +- **THEN** 系统更新记录并递增版本 +- **AND** 返回最新内容 + +#### Scenario: 使用过期版本更新 + +- **WHEN** COMPANY 使用过期版本提交知识内容 +- **THEN** 系统返回 409 `ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE` +- **AND** 不覆盖数据库中的新内容 + +#### Scenario: 非法内容 + +- **WHEN** 请求字段超出最大长度或包含不受支持的格式 +- **THEN** 系统返回 400 `BAD_REQUEST` +- **AND** 不写入知识记录 + +### Requirement: 公司知识权限与所有权隔离 + +学生读取接口 MUST 仅允许 STUDENT;企业配置接口 MUST 仅允许 COMPANY 且只操作当前用户拥有的企业。任何跨企业读取配置或修改操作 MUST 被拒绝。 + +#### Scenario: 企业访问本企业配置 + +- **WHEN** COMPANY 读取或更新自己的知识记录 +- **THEN** 系统允许操作 + +#### Scenario: 非企业访问企业配置 + +- **WHEN** STUDENT、ADMIN 或未登录用户访问企业知识配置接口 +- **THEN** 系统返回 401 或 403 +- **AND** 不读取或修改企业配置 + +#### Scenario: 不存在的企业 + +- **WHEN** STUDENT 请求不存在的公司知识 +- **THEN** 系统返回 404 `COMPANY_NOT_FOUND` + +### Requirement: 不改变既有评价与企业接口语义 + +本变更 MUST 只读复用既有企业、岗位、评价和维度评分数据,不得修改既有表结构、响应字段或权限语义。 + +#### Scenario: 既有评价接口回归 + +- **WHEN** 执行已有评价与企业模块测试 +- **THEN** 所有既有测试保持通过 +- **AND** OpenAPI 中既有路径的请求与响应语义不变 diff --git a/specs/learning-path/spec.md b/specs/learning-path/spec.md new file mode 100644 index 0000000..613d2ca --- /dev/null +++ b/specs/learning-path/spec.md @@ -0,0 +1,88 @@ +# learning path Specification + +## Purpose + +定义基于缺失 TOP10 技能的确定性学习路径生成行为。 + +## Requirements + +### Requirement: Learning path consumes unmatched TOP10 skills + +学习路径接口 MUST 复用个人技能匹配结果,并只包含当前 TOP10 中未命中的技能;不得包含已命中技能或 TOP10 之外的技能。 + +#### Scenario: Student has skill gaps + +- **WHEN** 当前 TOP10 中部分技能未命中 +- **THEN** 学习路径 MUST 包含全部未命中技能且不含已命中技能 + +#### Scenario: Student has no skill gaps + +- **WHEN** 当前 TOP10 全部命中 +- **THEN** 学习路径 MUST 返回空列表和总数 0 + +#### Scenario: Student has no matching skill evidence + +- **WHEN** 学生没有技能数据 +- **THEN** 学习路径 MUST 包含当前 TOP10 的全部技能 + +### Requirement: Learning priority follows heat order + +学习路径项 MUST 按与 TOP10 相同的热度顺序排列,并从 1 开始连续编号;空热度技能继续遵守词云的末位排序规则。 + +#### Scenario: Multiple missing skills are ordered + +- **WHEN** 缺失技能包含多个热度不同的技能 +- **THEN** 热度最高的技能 MUST 获得优先级 1,后续技能依次编号 + +#### Scenario: Missing skills have equal heat + +- **WHEN** 缺失技能热度相同 +- **THEN** 优先级 MUST 沿用词云的稳定次级排序,重复请求结果一致 + +#### Scenario: Only one skill is missing + +- **WHEN** 当前 TOP10 只有一项未命中 +- **THEN** 该技能 MUST 返回优先级 1 + +### Requirement: Learning suggestions are deterministic and industry-neutral + +每项学习建议 MUST 由后端按技能分类的通用模板生成,不调用 AI、知识库问答或外部课程服务;建议不得包含课程链接、讲师、价格、学习时长或完成度。 + +#### Scenario: Programming language gap is returned + +- **WHEN** 缺失技能属于编程语言分类 +- **THEN** 建议 MUST 描述基础语法学习和编码练习方向 + +#### Scenario: Framework gap is returned + +- **WHEN** 缺失技能属于框架分类 +- **THEN** 建议 MUST 描述核心概念学习和小型项目实践方向 + +#### Scenario: Tool gap is returned + +- **WHEN** 缺失技能属于工具分类 +- **THEN** 建议 MUST 描述安装配置和工程实践方向 + +#### Scenario: Unknown category is returned + +- **WHEN** 缺失技能无法匹配特定模板分类 +- **THEN** 系统 MUST 返回一条行业无关的通用学习建议 + +### Requirement: Learning path response is actionable and minimal + +每个学习路径项 MUST 返回技能 ID、技能名称、技能分类、优先级和简化学习建议;响应 MUST 不包含个人隐私、简历原文或模型推理内容。 + +#### Scenario: A learning item is generated + +- **WHEN** 存在未命中技能 +- **THEN** 每个路径项 MUST 同时具备技能身份、分类、连续优先级和建议文本 + +#### Scenario: Repeated request is made + +- **WHEN** 学生资料、词库数据和筛选条件均未变化 +- **THEN** 接口 MUST 返回相同顺序、相同优先级和相同建议 + +#### Scenario: Response contains no private profile data + +- **WHEN** 学习路径成功返回 +- **THEN** 响应 MUST 不含手机号、邮箱、简历文件、原始 `skills_json` 或其他无关个人数据 diff --git a/specs/onboarding-checklist/spec.md b/specs/onboarding-checklist/spec.md new file mode 100644 index 0000000..8825abe --- /dev/null +++ b/specs/onboarding-checklist/spec.md @@ -0,0 +1,155 @@ +# Onboarding Checklist Specification + +## Purpose + +定义学生入职清单读取、个人完成状态、企业自定义项管理和通用清单回退行为。 + +## Requirements + +### Requirement: 学生读取有效入职清单 + +系统 SHALL 允许已登录 STUDENT 按 `companyId` 读取由通用清单与企业活动自定义项组成的有效入职清单。响应 MUST 包含企业、当前学生完成状态、完成数量、总数量、完成率、是否是企业自定义配置,以及按 `sortOrder`、`id` 稳定排序的条目。 + +#### Scenario: 企业未配置自定义项 + +- **WHEN** STUDENT 请求存在企业的清单,且该企业没有活动自定义项 +- **THEN** 系统返回全部活动通用清单项 +- **AND** `customized` 为 `false` +- **AND** 每个条目返回标准来源、分类、标题、说明、必填标记和当前学生的完成状态 + +#### Scenario: 企业已配置自定义项 + +- **WHEN** STUDENT 请求存在企业的清单,且该企业存在活动自定义项 +- **THEN** 系统返回活动通用项与企业自定义项 +- **AND** 通用项与企业项使用稳定排序,响应 `customized` 为 `true` +- **AND** 每个条目的 `itemId` 和 `source` 足以用于后续完成状态更新 + +#### Scenario: 企业不存在 + +- **WHEN** STUDENT 请求不存在的 `companyId` +- **THEN** 系统返回 404 与既有 `COMPANY_NOT_FOUND` 错误码 +- **AND** 不创建完成状态行 + +### Requirement: 学生更新个人清单完成状态 + +系统 SHALL 允许 STUDENT 通过 `PUT /onboarding/companies/{companyId}/checklist/items/{itemId}/completion` 将本人对指定有效条目的状态设置为完成或未完成。请求 MUST 使用绝对状态语义,重复提交同一状态 MUST 不创建重复记录。响应 MUST 返回条目状态、版本和更新后的完成率。 + +#### Scenario: 首次标记完成 + +- **WHEN** STUDENT 对本人可访问的条目提交 `completed=true` +- **THEN** 系统创建该学生与该条目的完成记录 +- **AND** 设置 `completedAt` +- **AND** 返回更新后的完成数量、总数量和完成率 + +#### Scenario: 重复设置相同状态 + +- **WHEN** STUDENT 对同一条目重复提交相同 `completed` 值 +- **THEN** 系统返回幂等成功结果 +- **AND** 数据库仍只有一条学生与条目完成记录 +- **AND** 不改变首次完成后的业务结果 + +#### Scenario: 取消完成 + +- **WHEN** STUDENT 对已完成条目的提交 `completed=false` +- **THEN** 系统将记录置为未完成 +- **AND** 清除 `completedAt` +- **AND** 返回更新后的完成率 + +#### Scenario: 条目不属于该企业 + +- **WHEN** STUDENT 使用属于另一企业自定义项的 `itemId` 调用更新接口 +- **THEN** 系统返回 404 `ONBOARDING_CHECKLIST_ITEM_NOT_FOUND` +- **AND** 不允许创建或修改完成记录 + +### Requirement: 企业创建自定义清单项 + +系统 SHALL 允许 COMPANY 为本企业创建自定义清单项。请求 MUST 校验标题、说明、分类、排序值和必填标记。请求携带 `idempotencyKey` 时,同一企业、同一 key 且请求体相同 MUST 返回原条目;请求体不同 MUST 返回 409 `ONBOARDING_IDEMPOTENCY_CONFLICT`。 + +#### Scenario: 创建成功 + +- **WHEN** COMPANY 提交合法的标题、分类和可选说明 +- **THEN** 系统创建仅属于该企业的活动条目 +- **AND** 返回条目 ID、来源 `COMPANY`、版本、排序值和必填标记 + +#### Scenario: 幂等重放 + +- **WHEN** COMPANY 使用相同 `idempotencyKey` 和相同请求体重复创建 +- **THEN** 系统返回第一次创建的条目 +- **AND** 数据库不产生第二条记录 + +#### Scenario: 幂等键内容冲突 + +- **WHEN** COMPANY 重复使用同一 `idempotencyKey`,但标题、分类、说明、排序或必填标记不同 +- **THEN** 系统返回 409 `ONBOARDING_IDEMPOTENCY_CONFLICT` + +#### Scenario: 超出企业条目上限 + +- **WHEN** COMPANY 已拥有 50 条活动自定义项并继续创建 +- **THEN** 系统返回 409 `ONBOARDING_ITEM_LIMIT_EXCEEDED` +- **AND** 不写入第 51 条活动条目 + +### Requirement: 企业查询和更新自定义清单项 + +系统 SHALL 允许 COMPANY 查询本企业全部自定义项,并通过带版本号的更新请求修改标题、说明、分类、排序、必填标记和活动状态。版本冲突 MUST 返回 409 `ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE`,不得静默覆盖。 + +#### Scenario: 查询本企业条目 + +- **WHEN** COMPANY 请求本企业自定义条目列表 +- **THEN** 系统返回该企业条目,包括活动与软删除条目及当前版本 +- **AND** 不返回其他企业的条目 + +#### Scenario: 正常更新 + +- **WHEN** COMPANY 使用当前 `version` 更新本企业条目 +- **THEN** 系统保存修改并递增版本 +- **AND** 返回更新后的条目 + +#### Scenario: 版本过期 + +- **WHEN** COMPANY 使用过期 `version` 更新条目 +- **THEN** 系统返回 409 `ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE` +- **AND** 不覆盖数据库中较新的值 + +### Requirement: 企业软删除自定义清单项 + +系统 SHALL 允许 COMPANY 通过带当前 `version` 的删除请求软删除本企业条目。软删除 MUST 只将条目标记为非活动,不物理删除条目或学生历史完成记录。重复删除已非活动条目 MUST 返回幂等成功结果。 + +#### Scenario: 首次删除 + +- **WHEN** COMPANY 使用当前版本删除本企业活动条目 +- **THEN** 系统将条目 `active` 置为 `false` +- **AND** 该条目不再出现在学生有效清单中 +- **AND** 既有完成记录保留 + +#### Scenario: 重复删除 + +- **WHEN** COMPANY 再次删除已软删除条目 +- **THEN** 系统返回成功 +- **AND** 不产生第二次数据变更 + +#### Scenario: 删除其他企业条目 + +- **WHEN** COMPANY 删除不属于本企业的条目 +- **THEN** 系统返回 404 `ONBOARDING_CHECKLIST_ITEM_NOT_FOUND` +- **AND** 目标条目保持原状态 + +### Requirement: 清单权限和数据最小化 + +学生接口 MUST 仅允许 STUDENT;企业配置接口 MUST 仅允许 COMPANY 且操作绑定当前用户拥有的企业。所有响应 MUST 使用白名单 DTO,不返回用户密码、Token、IP、User-Agent、评价审核原因或其他企业内部的非必要字段。 + +#### Scenario: 非学生访问学生清单 + +- **WHEN** COMPANY、ADMIN 或未登录用户访问学生清单接口 +- **THEN** 系统返回 401 或 403 +- **AND** 不返回清单或完成状态 + +#### Scenario: 非企业访问企业配置 + +- **WHEN** STUDENT、ADMIN 或未登录用户访问企业配置接口 +- **THEN** 系统返回 401 或 403 +- **AND** 不创建或修改企业条目 + +#### Scenario: 企业无绑定资料 + +- **WHEN** COMPANY 用户尚未创建企业资料却调用企业配置接口 +- **THEN** 系统返回 404 `COMPANY_NOT_FOUND` diff --git a/specs/salary-calculation/spec.md b/specs/salary-calculation/spec.md new file mode 100644 index 0000000..1ccac01 --- /dev/null +++ b/specs/salary-calculation/spec.md @@ -0,0 +1,152 @@ +# salary-calculation + +## Purpose + +为已登录求职者提供基于城市当前政策、用户显式覆盖和个人综合所得累计预扣法的到手工资估算能力。 + +## Requirements + +### Requirement: 仅已登录 STUDENT 可访问薪资计算模块 + +The system SHALL 要求薪资城市查询、计算和历史接口均通过 JWT 认证,并且当前用户必须具有 STUDENT 角色。未认证请求 MUST 返回 401;已认证但非 STUDENT 的请求 MUST 返回 403。 + +#### Scenario: 未登录访问城市列表 + +- **WHEN** 客户端未携带有效访问令牌调用 `GET /salary-calculator/cities` +- **THEN** 系统返回 401,且不返回任何城市政策数据 + +#### Scenario: 企业用户调用计算接口 + +- **WHEN** 已登录的 COMPANY 用户调用 `POST /salary-calculator/calculations` +- **THEN** 系统返回 403,且不创建历史记录 + +### Requirement: 查询当前启用的城市政策目录 + +The system SHALL 提供城市列表和城市详情接口。城市列表 MUST 只返回 `enabled=true` 的城市摘要,并按省份编码、城市编码稳定排序;城市详情 MUST 返回城市元数据、政策版本、生效日期和当前整套默认参数。 + +#### Scenario: 获取全部启用城市 + +- **WHEN** STUDENT 调用 `GET /salary-calculator/cities` +- **THEN** 系统按省份编码和城市编码返回所有启用城市摘要,且不包含区县记录 + +#### Scenario: 获取城市默认参数 + +- **WHEN** STUDENT 调用 `GET /salary-calculator/cities/{cityCode}` 且该城市已启用 +- **THEN** 系统返回六位城市编码、省市名称、政策版本、生效日期、`dataLevel`、社保和公积金基数上下限、个人比例及大病固定费 + +### Requirement: 城市配置以地级行政区和省直辖县级市为最小单元 + +The system SHALL 以六位行政区划代码作为城市政策主键,覆盖中国大陆全部地级行政区、直辖市和省直辖县级市,不拆分区县,不包含港澳台。 + +#### Scenario: 直辖市使用一条配置 + +- **WHEN** 客户端查询北京市或重庆市 +- **THEN** 系统返回单一城市级配置,而不是按区县返回多条配置 + +#### Scenario: 市内存在分区差异 + +- **WHEN** 同一地级市不同区县存在政策差异 +- **THEN** 系统仍只使用该地级市的一条当前配置,并在估算声明中提示结果可能受同城差异影响 + +### Requirement: 校验城市政策版本和元数据 + +The system SHALL 要求每条城市政策具有唯一的六位编码、非空且不晚于采集截止日的生效日期、非空政策版本、合法参数以及 `dataLevel`。`CITY` 表示已核验的城市级参数;`PROVINCE_FALLBACK` 表示使用所属省级公开参数的近似配置。任何参数或生效日期变化 MUST 同步更新 `policyVersion`。 + +#### Scenario: 客户端使用已失效的政策版本计算 + +- **WHEN** STUDENT 提交的 `expectedPolicyVersion` 与当前城市版本不一致 +- **THEN** 系统返回 409/9805,响应包含最新 `policyVersion` 和 `policyEffectiveDate`,且不生成历史记录 + +#### Scenario: 城市政策种子存在非法参数 + +- **WHEN** `V41` 中存在上下限倒置、比例超限或缺失必填字段的城市记录 +- **THEN** 迁移或种子集成测试失败,禁止进入验收 + +#### Scenario: 使用省级回退参数的城市 + +- **WHEN** 某目标城市没有可核验的城市级参数,V41 使用所属省级公开参数创建记录 +- **THEN** 该记录必须标记为 PROVINCE_FALLBACK,城市详情、计算响应和历史快照都必须明确返回该精度等级 +- **THEN** 系统不得把该记录展示为 CITY 或声称其达到城市级官方精确度 + +### Requirement: 用户覆盖参数优先于城市默认值 + +The system SHALL 允许用户分别覆盖社保基数、公积金基数、养老比例、医疗比例、失业比例、公积金比例和大病固定费。未覆盖字段 MUST 使用城市默认值;显式覆盖值 MUST 通过城市上下限和全国合理性范围校验。 + +#### Scenario: 只覆盖公积金基数 + +- **WHEN** 用户提交新的公积金基数但未提交比例覆盖 +- **THEN** 系统使用用户公积金基数和城市默认公积金比例,其余项目继续使用城市默认值 + +#### Scenario: 覆盖基数超出城市范围 + +- **WHEN** 用户提交低于社保下限的社保基数 +- **THEN** 系统返回 400/9802,且不生成历史记录 + +### Requirement: 计算个人五险一金和大病固定费 + +The system SHALL 按社保基数计算个人养老、医疗和失业,按公积金基数计算个人公积金,并按城市或用户覆盖值扣除按月大病固定费。工伤和生育保险、单位缴费和企业总成本 MUST 不计入个人到手工资。 + +#### Scenario: 未覆盖任何参数 + +- **WHEN** 用户输入税前月薪且未提供覆盖项 +- **THEN** 社保基数按税前月薪截断到社保区间,公积金基数按税前月薪截断到公积金区间,各个人项目按对应比例计算 + +#### Scenario: 用户将某项比例覆盖为 0 + +- **WHEN** 用户将失业比例覆盖为 0 +- **THEN** 个人失业扣款为 0,其他项目按各自生效参数计算 + +### Requirement: 使用累计月份和专项附加扣除计算应纳税所得额 + +The system SHALL 接受 `monthIndex`(1–12)和月度专项附加扣除。`monthIndex` MUST 表示本年度当前工资口径下已经累计计税的月份数,并假设前序月份的收入、个人缴费和专项附加扣除与本次输入相同。 + +#### Scenario: 首次领薪但自然月为九月 + +- **WHEN** 用户九月首次领薪并提交 `monthIndex=1` +- **THEN** 系统按一个月的累计收入和扣除计算,而不是按九个月计算 + +#### Scenario: 累计月份为十二 + +- **WHEN** 用户提交 `monthIndex=12` +- **THEN** 系统按十二个月的相同工资、个人缴费和专项附加扣除推导全年累计口径 + +### Requirement: 按居民个人综合所得累计预扣法计算个税 + +The system SHALL 使用全国统一的居民个人综合所得年度累进预扣税率表和速算扣除数。基本减除费用 MUST 固定为每月 5,000 元,累计应纳税所得额小于等于 0 时 MUST 按 0 计税,当月税额 MUST 不得为负。 + +#### Scenario: 累计应纳税所得额低于起征线 + +- **WHEN** 累计收入扣除个人缴费、专项附加扣除和 5,000 × monthIndex 后不大于 0 +- **THEN** 累计应纳税额为 0,当月个税为 0 + +#### Scenario: 月薪进入更高累计税率档 + +- **WHEN** 累计应纳税所得额超过当前档位上限 +- **THEN** 系统按完整年度累进税率表计算累计应纳税额,并减去按相同假设推导的累计已预扣税额 + +### Requirement: 输出到手工资和完整计算拆解 + +The system SHALL 返回税前月薪、城市及政策版本、社保基数、公积金基数、个人养老、个人医疗、个人失业、个人公积金、大病固定费、基本减除费用、专项附加扣除、累计月份、累计与当月计税链路、当月个税和到手工资。响应 MUST 标记 `estimate=true`。 + +#### Scenario: 计算成功 + +- **WHEN** STUDENT 提交合法的城市、政策版本和工资参数 +- **THEN** 系统返回完整拆解、`estimate=true`、全国税率版本、城市政策版本与生效日期,并返回可访问的历史 ID + +### Requirement: 校验工资输入并限制金额精度 + +The system SHALL 要求税前月薪大于 0 且不超过 10,000,000 元,专项附加扣除在 0–10,000,000 元之间,所有用户金额输入最多两位小数。比例 MUST 使用小数表示,且个人上限为养老 20%、医疗 20%、失业 10%、公积金 12%;大病固定费不得超过 10,000 元/月。 + +#### Scenario: 金额超过两位小数 + +- **WHEN** 用户提交 `0.001` 元的金额字段 +- **THEN** 系统返回 400/9802,且不执行计算 + +### Requirement: 返回稳定的城市和历史错误语义 + +The system SHALL 使用 `400` 表示参数非法,`404` 表示城市或历史不存在,`409` 表示幂等或政策版本冲突。`9801` MUST 表示城市不存在或停用,`9802` MUST 表示参数非法,`9803` MUST 表示历史不存在或无权访问,`9804` MUST 表示幂等冲突,`9805` MUST 表示政策版本冲突。 + +#### Scenario: 城市停用 + +- **WHEN** 用户提交已停用城市的计算请求 +- **THEN** 系统返回 404/9801,且不生成历史记录 diff --git a/specs/salary-history/spec.md b/specs/salary-history/spec.md new file mode 100644 index 0000000..f1ecf3e --- /dev/null +++ b/specs/salary-history/spec.md @@ -0,0 +1,126 @@ +# salary-history + +## Purpose + +保存求职者每次成功薪资估算的可追溯快照,并提供仅限本人访问的分页查询、详情和物理删除能力。 + +## Requirements + +### Requirement: 成功计算自动保存历史 + +The system SHALL 在计算并保存接口成功完成时自动创建一条历史记录,并在响应中返回历史 ID 和创建时间。系统 MUST NOT 要求调用方再调用第二个保存接口。 + +#### Scenario: 首次成功计算 + +- **WHEN** STUDENT 提交合法且政策版本一致的计算请求 +- **THEN** 系统在同一事务中完成计算并保存历史,返回结果及新历史 ID + +### Requirement: 历史保存完整且可重建的快照 + +The system SHALL 在历史记录中保存原始输入、城市编码与名称、`dataLevel`、城市默认参数、用户覆盖项、最终生效参数、完整结果、`monthIndex`、城市政策版本、政策生效日期、全国税率版本和创建时间。三类 JSON 快照 MUST 能独立重建当时结果。 + +#### Scenario: 城市参数后来变化 + +- **WHEN** 历史创建后城市默认参数或政策版本发生变化 +- **THEN** 历史详情仍返回创建时的输入、政策、覆盖项和完整结果,不重新读取当前城市配置,包括当时的 `CITY` 或 `PROVINCE_FALLBACK` 精度标识 + +### Requirement: 计算保存使用幂等键防重 + +The system SHALL 支持可选 `idempotencyKey`,最长 64 字符,空白值按未提供处理。同一用户相同幂等键且规范化业务字段相同 MUST 返回原历史;相同幂等键但规范化字段不同 MUST 返回 409/9804。 + +#### Scenario: 相同请求网络重试 + +- **WHEN** 客户端使用相同用户、相同幂等键和相同规范化业务字段重试计算 +- **THEN** 系统返回同一条历史和相同计算结果,不新增数据库行 + +#### Scenario: 相同幂等键不同请求体 + +- **WHEN** 客户端复用幂等键但修改税前月薪或政策版本 +- **THEN** 系统返回 409/9804,且不创建第二条历史 + +### Requirement: 同一幂等键并发只能生成一条历史 + +The system SHALL 使用事务和数据库唯一约束处理并发。相同用户相同幂等键并发请求 MUST 最多生成一条历史;同键不同请求并发时,一方成功,另一方 MUST 返回 409/9804。 + +#### Scenario: 并发相同请求 + +- **WHEN** 两个请求同时提交相同用户、幂等键和规范化业务字段 +- **THEN** 数据库中最多存在一条历史,两个成功响应指向同一历史 ID + +### Requirement: 历史列表和详情仅返回本人数据 + +The system SHALL 提供按创建时间倒序、ID 倒序的分页列表和详情接口。列表 MUST 只返回摘要;详情 MUST 返回完整快照。查询必须同时按当前登录用户和历史 ID 过滤。 + +#### Scenario: 查询本人历史 + +- **WHEN** STUDENT 调用历史列表或详情接口 +- **THEN** 系统只返回该 STUDENT 的历史,按最新创建时间排序 + +#### Scenario: 猜测他人历史 ID + +- **WHEN** STUDENT 请求不属于自己的历史 ID +- **THEN** 系统返回 404/9803,不泄露该记录是否存在 + +### Requirement: 历史不可编辑且可以物理删除单条 + +The system SHALL 不提供历史更新接口。历史创建后不可修改;用户删除本人历史时 MUST 物理删除该行及其幂等键,后续可以为该幂等键创建新历史。 + +#### Scenario: 删除本人历史 + +- **WHEN** STUDENT 删除自己的历史记录 +- **THEN** 系统物理删除该记录并返回成功响应,后续详情查询返回 404/9803 + +#### Scenario: 重复删除历史 + +- **WHEN** STUDENT 再次删除已经删除的历史 +- **THEN** 系统返回 404/9803,不返回成功状态 + +### Requirement: 历史列表采用统一分页且不提供额外筛选 + +The system SHALL 使用 `page=0`、`size=20` 的分页默认值,`size` 最大为 100;首期 MUST NOT 增加城市、日期范围或薪资范围筛选。 + +#### Scenario: 超出分页上限 + +- **WHEN** 客户端请求 `size=101` +- **THEN** 系统返回 400/9802,不返回历史列表 + +### Requirement: 历史数据按用户长期保留 + +The system SHALL 允许历史记录长期保留直至用户主动删除,不设置自动过期、定时清理或业务数量配额。城市停用、政策更新或历史数量增长 MUST NOT 影响历史详情读取。 + +#### Scenario: 城市停用后查看旧历史 + +- **WHEN** 历史所属城市后来被停用 +- **THEN** STUDENT 仍可查看该历史详情 + +### Requirement: 历史不进入审计日志或个人数据导出 + +The system SHALL 不因薪资计算或单条历史删除写入业务审计日志,也不得把税前工资、基数、税额、到手工资或 JSON 快照写入应用日志。薪资历史 MUST NOT 纳入现有个人数据导出 ZIP。 + +#### Scenario: 计算成功后的日志检查 + +- **WHEN** 系统完成一次薪资计算和历史保存 +- **THEN** 日志仅包含请求 ID、用户 ID、历史 ID、城市编码、状态和耗时,不包含金额或参数快照 + +### Requirement: 账号注销清理薪资历史 + +The system SHALL 在账号注销完成事务中物理删除该用户全部薪资历史。注销处于 `PENDING` 时 MUST 禁止计算和删除写入,`COMPLETED` 时所有薪资接口 MUST 返回 409/9303。 + +#### Scenario: 注销完成后清理历史 + +- **WHEN** 用户账号注销完成 +- **THEN** 数据库中不存在该用户的 `salary_calculation_history` 行 + +#### Scenario: 注销冷静期尝试计算 + +- **WHEN** 用户处于 `PENDING` 状态调用计算接口 +- **THEN** 现有注销守卫返回 409/9302,且不创建历史 + +### Requirement: 数据库迁移保证城市政策和历史完整性 + +The system SHALL 通过 `V40` 创建 `salary_city_policy` 与 `salary_calculation_history`,通过 `V41` 写入目标城市种子。城市编码 MUST 唯一,历史幂等键 MUST 按用户唯一,历史快照 MUST 保留且不依赖后续城市更新。 + +#### Scenario: 迁移初始化 + +- **WHEN** 在 MySQL 或 H2 MySQL 模式执行 `V40` 和 `V41` +- **THEN** 城市政策表、历史表、主外键、唯一键和索引创建成功,目标城市种子导入成功 diff --git a/specs/skill-cloud/spec.md b/specs/skill-cloud/spec.md new file mode 100644 index 0000000..5201287 --- /dev/null +++ b/specs/skill-cloud/spec.md @@ -0,0 +1,102 @@ +# skill cloud Specification + +## Purpose + +定义行业技能词云查询、筛选、热度排序与来源追溯行为。 + +## Requirements + +### Requirement: STUDENT-only skill cloud access + +系统 MUST 只允许已登录 STUDENT 调用技能词云接口,并从 JWT 解析当前用户,不接受客户端传入学生身份参数。 + +#### Scenario: Anonymous request is rejected + +- **WHEN** 未携带有效 JWT 请求 `GET /api/v1/skills/word-cloud` +- **THEN** 系统 MUST 返回 401,且不得返回任何词库数据 + +#### Scenario: Company or administrator request is rejected + +- **WHEN** COMPANY 或 ADMIN 用户携带有效 JWT 请求技能词云 +- **THEN** 系统 MUST 返回 403,且不得返回任何词库数据 + +#### Scenario: Student request is accepted + +- **WHEN** STUDENT 用户携带有效 JWT 请求技能词云 +- **THEN** 系统 MUST 按筛选条件返回词库数据 + +### Requirement: Skill cloud filter validation + +接口 MUST 要求 `industryId` 和 `occupationId` 为必填正整数,`categoryId` 为可选正整数;行业与职位必须存在且互为关联,技能分类存在时必须属于该职位。 + +#### Scenario: Required filters are missing + +- **WHEN** 请求未传 `industryId` 或 `occupationId` +- **THEN** 系统 MUST 返回 400,并说明缺失的筛选参数 + +#### Scenario: Occupation does not belong to industry + +- **WHEN** 请求传入存在但不属于目标行业的职位 +- **THEN** 系统 MUST 返回 400,且不得返回技能数据 + +#### Scenario: Category does not belong to occupation + +- **WHEN** 请求传入存在但不属于目标职位的技能分类 +- **THEN** 系统 MUST 返回 400,且不得返回技能数据 + +#### Scenario: Category is omitted + +- **WHEN** 请求只传行业和职位 +- **THEN** 系统 MUST 返回该职位下所有技能分类的技能 + +### Requirement: Complete ranked skill cloud + +系统 MUST 返回当前筛选范围内全部技能,并按 `heat` 降序排列;`heat` 为空时排在非空值之后。同 `heat` 或同为空值时,系统 MUST 使用稳定的中文技能名称和技能 ID 作为次级排序条件。 + +#### Scenario: Skills are ordered by heat + +- **WHEN** 筛选结果存在多个带 O*NET Importance 的技能 +- **THEN** 返回列表 MUST 按数值从高到低排列 + +#### Scenario: Skills without heat are retained + +- **WHEN** 筛选结果包含无 O*NET Importance 的 ESCO 技能 +- **THEN** 这些技能 MUST 保留在列表中,`heat` 为 null,并排在所有非 null 热度技能之后 + +#### Scenario: Equal heat has deterministic order + +- **WHEN** 两个技能具有相同 `heat` +- **THEN** 系统 MUST 按稳定次级规则返回一致顺序,重复请求不得随机变化 + +#### Scenario: Empty scope returns empty list + +- **WHEN** 合法筛选范围内没有技能 +- **THEN** 系统 MUST 返回成功响应、空技能列表和总数 0 + +### Requirement: TOP10 is derived from the skill cloud + +系统 MUST NOT 为 TOP10 维护独立排序或独立数据源;前端必须能够直接使用词云返回列表的前十条得到 TOP10。 + +#### Scenario: Scope has at least ten skills + +- **WHEN** 词云结果包含至少十条技能 +- **THEN** 前十条 MUST 与同一筛选条件下的热门技能 TOP10 一致 + +#### Scenario: Scope has fewer than ten skills + +- **WHEN** 词云结果少于十条技能 +- **THEN** 全部结果 MUST 构成实际 TOP10 集合 + +### Requirement: Skill cloud response exposes source-linked metadata + +每个技能项 MUST 至少返回技能 ID、中文名称、技能分类 ID、技能分类名称、`heat` 和稳定排名。技能仍可通过 ID 追溯至 ESCO/O*NET 来源。 + +#### Scenario: Skill item is returned + +- **WHEN** 查询返回任一技能 +- **THEN** 响应 MUST 包含该技能的身份、分类、热度和排名字段 + +#### Scenario: Response is wrapped consistently + +- **WHEN** 请求成功 +- **THEN** 响应 MUST 使用项目统一 `ApiResponse` 结构,并在 `data` 中返回筛选条件和技能列表 diff --git a/specs/skill-match/spec.md b/specs/skill-match/spec.md new file mode 100644 index 0000000..823208d --- /dev/null +++ b/specs/skill-match/spec.md @@ -0,0 +1,102 @@ +# skill match Specification + +## Purpose + +定义个人技能与当前热门 TOP10 的确定性匹配行为。 + +## Requirements + +### Requirement: Personal matching uses the current ranked TOP10 + +个人技能匹配接口 MUST 复用技能词云相同的筛选参数、数据范围和热度排序;匹配集合 MUST 是当前筛选结果的前 `min(10, 实际技能数)` 项。 + +#### Scenario: Filtered result has at least ten skills + +- **WHEN** 当前筛选返回十条或更多技能 +- **THEN** 匹配集合 MUST 恰好包含热度最高的十条技能 + +#### Scenario: Filtered result has fewer than ten skills + +- **WHEN** 当前筛选返回六条技能 +- **THEN** 匹配集合 MUST 包含全部六条技能,分母 MUST 为 6 + +#### Scenario: Filtered result is empty + +- **WHEN** 当前合法筛选范围内没有技能 +- **THEN** 匹配度 MUST 返回 0,命中和缺失列表 MUST 均为空 + +### Requirement: Student skills are read from the authenticated profile + +系统 MUST 从当前登录 STUDENT 的 `student_profiles.skills_json` 读取技能;不得接受客户端另行提交的技能列表作为个人匹配依据。 + +#### Scenario: Student profile has valid skills + +- **WHEN** 当前学生存在合法技能数组 +- **THEN** 系统 MUST 使用该数组计算匹配结果 + +#### Scenario: Student profile is absent or has no skills + +- **WHEN** 当前学生没有资料记录、技能字段为空或技能数组为空 +- **THEN** 匹配度 MUST 返回 0,命中列表为空,缺失列表为当前 TOP10 + +#### Scenario: Skills JSON is malformed + +- **WHEN** 当前学生技能字段无法解析为字符串数组 +- **THEN** 系统 MUST 按无技能处理,不得导致 500 响应或泄露原始字段内容 + +### Requirement: Skill matching normalizes aliases + +系统 MUST 使用技能标准和别名进行匹配,归一化至少覆盖首尾空白、大小写、连续空格和常见标点差异;每个标准技能命中一次即可。 + +#### Scenario: Alias matches canonical skill + +- **WHEN** 学生技能为 `SpringBoot`,TOP10 标准技能为 `Spring Boot` 且存在对应别名 +- **THEN** 系统 MUST 将该技能计为命中 + +#### Scenario: Case and spacing differ + +- **WHEN** 学生技能与标准技能的字母大小写或空格数量不同 +- **THEN** 系统 MUST 在归一化后判断为同一技能 + +#### Scenario: Duplicate student skills exist + +- **WHEN** 学生技能数组包含多个指向同一标准技能的别名 +- **THEN** 系统 MUST 只计一次命中 + +### Requirement: Match score uses the actual TOP10 size + +匹配度 MUST 使用公式 `round(命中数 × 100 / min(10, 实际技能数))` 计算,并返回 0 到 100 的整数。实际技能数为 0 时,匹配度 MUST 为 0。 + +#### Scenario: All skills match + +- **WHEN** 当前 TOP10 有 8 项,学生命中全部 8 项 +- **THEN** 匹配度 MUST 返回 100 + +#### Scenario: Partial match is rounded + +- **WHEN** 当前 TOP10 有 3 项,学生命中 1 项 +- **THEN** 匹配度 MUST 返回 33 + +#### Scenario: No skill matches + +- **WHEN** 当前 TOP10 非空且学生没有命中任何技能 +- **THEN** 匹配度 MUST 返回 0 + +### Requirement: Match response separates matched and missing skills + +响应 MUST 返回匹配度、命中数量、实际匹配集合大小、命中技能列表和缺失技能列表;缺失技能 MUST 保持 TOP10 的热度排序。 + +#### Scenario: Some skills are matched + +- **WHEN** 学生命中 TOP10 的部分技能 +- **THEN** 命中列表和缺失列表 MUST 分别返回对应技能,且并集等于 TOP10 + +#### Scenario: All skills are matched + +- **WHEN** 学生命中 TOP10 的全部技能 +- **THEN** 缺失技能列表 MUST 为空,匹配度 MUST 为 100 + +#### Scenario: No skills are matched + +- **WHEN** 学生没有命中 TOP10 的任何技能 +- **THEN** 命中技能列表 MUST 为空,缺失列表 MUST 等于 TOP10