Browse Source

Merge pull request 'feat: integrate skill cloud onboarding and salary modules' (#2) from integration/lxq-n03-n05-20261008 into release/identity-selection-20260929

Reviewed-on: baskbuddy/zhangshun#2

Final verification: 2 commits, 149 changed files, no deleted files.
mvn clean verify: 3244 tests, failures=0, errors=0, skipped=0. OpenAPI: 196 paths / 239 operations.
Salary publication allowlist defaults to empty; unverified policy lookup/calculation remains closed. Historical records retain ownership checks.
V1-V46, authentication, security, pom.xml and application.yml unchanged; only V47-V53 added.
Merge into release/identity-selection-20260929 only; deployment is a separate step.
release/identity-selection-20260929
zhangshun 3 weeks ago
parent
commit
43739f42ed
  1. 77
      changes/n03-skill-cloud/data/source-manifest.md
  2. 1
      docs/backend-development-roadmap.md
  3. 4
      docs/frontend-integration.md
  4. 434
      docs/onboarding-api.md
  5. 11303
      docs/openapi-rc.json
  6. 198
      docs/salary-calculator-api.md
  7. 275
      docs/skill-cloud-api.md
  8. 79
      docs/skill-taxonomy-sources.md
  9. 77
      docs/verify_openapi.py
  10. 13
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  11. 10
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java
  12. 15
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
  13. 3
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java
  14. 63
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledge.java
  15. 74
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeController.java
  16. 12
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeRepository.java
  17. 412
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java
  18. 120
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java
  19. 95
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItem.java
  20. 39
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java
  21. 561
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java
  22. 64
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgress.java
  23. 19
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgressRepository.java
  24. 28
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ChecklistItemView.java
  25. 34
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemCreateRequest.java
  26. 41
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemUpdateRequest.java
  27. 24
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemView.java
  28. 53
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java
  29. 21
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeUpdateRequest.java
  30. 24
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeView.java
  31. 23
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/EffectiveChecklistResponse.java
  32. 15
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateRequest.java
  33. 23
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateResponse.java
  34. 37
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ReviewSummaryView.java
  35. 18
      mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java
  36. 33
      mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java
  37. 91
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java
  38. 262
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationEngine.java
  39. 86
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistory.java
  40. 23
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistoryRepository.java
  41. 19
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationInput.java
  42. 53
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationResult.java
  43. 190
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java
  44. 97
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicy.java
  45. 64
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyController.java
  46. 15
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyRepository.java
  47. 86
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyService.java
  48. 6
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryDataLevel.java
  49. 18
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryCleanupService.java
  50. 106
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryService.java
  51. 22
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryOverrideParameters.java
  52. 33
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyPublicationProperties.java
  53. 23
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyVersionConflictException.java
  54. 68
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryRequestFingerprint.java
  55. 53
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationOverrideRequest.java
  56. 51
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationRequest.java
  57. 19
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationResponse.java
  58. 34
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCityDetailResponse.java
  59. 23
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCitySummaryResponse.java
  60. 31
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistoryDetailResponse.java
  61. 25
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistorySummaryResponse.java
  62. 207
      mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudController.java
  63. 160
      mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudQueryService.java
  64. 95
      mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillLearningPathService.java
  65. 152
      mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillMatchService.java
  66. 28
      mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillNameNormalizer.java
  67. 62
      mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/OccupationSkill.java
  68. 56
      mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/Skill.java
  69. 41
      mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillAlias.java
  70. 50
      mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillCategory.java
  71. 47
      mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillIndustry.java
  72. 56
      mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillOccupation.java
  73. 50
      mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillSourceVersion.java
  74. 20
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudItem.java
  75. 8
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudNode.java
  76. 15
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudResponse.java
  77. 21
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudSkillProjection.java
  78. 11
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathItem.java
  79. 12
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathResponse.java
  80. 16
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillMatchResponse.java
  81. 73
      mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/OccupationSkillRepository.java
  82. 15
      mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillAliasRepository.java
  83. 13
      mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillCategoryRepository.java
  84. 7
      mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillIndustryRepository.java
  85. 13
      mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillOccupationRepository.java
  86. 15
      mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillRepository.java
  87. 7
      mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillSourceVersionRepository.java
  88. 120
      mydreamjob-server/src/main/resources/db/migration/V47__skill_cloud_taxonomy.sql
  89. 413
      mydreamjob-server/src/main/resources/db/migration/V48__skill_cloud_seed.sql
  90. 12
      mydreamjob-server/src/main/resources/db/migration/V49__skill_cloud_boolean_type.sql
  91. 57
      mydreamjob-server/src/main/resources/db/migration/V50__onboarding_checklist_and_company_knowledge.sql
  92. 31
      mydreamjob-server/src/main/resources/db/migration/V51__onboarding_standard_checklist_seed.sql
  93. 69
      mydreamjob-server/src/main/resources/db/migration/V52__salary_calculator_schema.sql
  94. 368
      mydreamjob-server/src/main/resources/db/migration/V53__salary_city_policy_seed.sql
  95. 15
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  96. 6
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java
  97. 19
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java
  98. 14
      mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java
  99. 8
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java
  100. 18
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java

77
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<br>复用条件说明: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:来源、覆盖、导入与责任边界中不得出现未决占位或延期确认语句。

1
docs/backend-development-roadmap.md

@ -6,6 +6,7 @@
| 范围 | 2026-09-08核对结果 | 下一动作 | | 范围 | 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做一期集成回归 | | 核心招聘与账户能力 | 已有代码与历史验收记录;本轮未重跑验收 | 按清单C01–C08做一期集成回归 |
| V39–V42 | 已核对提交55e3975、2698d06、5cc2c86、96b3113;不再标为待Git检查点 | 保留回归,不重复开发企业回复/发现/邀约 | | V39–V42 | 已核对提交55e3975、2698d06、5cc2c86、96b3113;不再标为待Git检查点 | 保留回归,不重复开发企业回复/发现/邀约 |
| V43 | 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(cf39737) | N01 已关闭版本内小样本补集泄露;30/300/3000 查询预算通过,EXPLAIN 扩容风险保留观察 | | V43 | 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(cf39737) | N01 已关闭版本内小样本补集泄露;30/300/3000 查询预算通过,EXPLAIN 扩容风险保留观察 |

4
docs/frontend-integration.md

@ -160,8 +160,8 @@ POST /api/v1/auth/logout header: Authorization: Bearer <accessToken> (
| 401 | 401(安全过滤器) | 未登录 / 无 token | | 401 | 401(安全过滤器) | 未登录 / 无 token |
| 403 | 2002, 8004, 8303, 8402 | 无角色权限 / 越权(含企业归属不匹配) | | 403 | 2002, 8004, 8303, 8402 | 无角色权限 / 越权(含企业归属不匹配) |
| 400 | 1001, 3002-3006, 8404-8408, 8505, 9105 | 参数错误 / 非法请求体 / 资源未命中默认映射 | | 400 | 1001, 3002-3006, 8404-8408, 8505, 9105 | 参数错误 / 非法请求体 / 资源未命中默认映射 |
| 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 8305, 8501 | 资源不存在 | | 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 | 重复 / 非法状态流转 | | 409 | 5003, 7003, 8001, 8003, 8301-8302, 8304, 8502-8504, 9101, 9103, 10502-10506, 10604-10605 | 重复 / 非法状态流转 / 版本或幂等冲突 |
| 500 | 1002, 9001 | 服务器内部错误 | | 500 | 1002, 9001 | 服务器内部错误 |
**前端策略**:优先判断 HTTP 状态码,再读取 `body.code` 显示业务信息。 **前端策略**:优先判断 HTTP 状态码,再读取 `body.code` 显示业务信息。

434
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 <accessToken>
Content-Type: application/json
X-Request-Id: <optional-trace-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 <studentAccessToken>
```
响应:
```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 <studentAccessToken>
{"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 <companyAccessToken>
{
"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 <companyAccessToken>
```
响应包含活动与软删除条目,不返回通用项和其他企业项。更新请求:
```http
PUT /api/v1/onboarding/company/checklist-items/501
Authorization: Bearer <companyAccessToken>
{
"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 <companyAccessToken>
```
首次删除必须使用当前版本,系统只将 `active=false`,不物理删除。重复删除已非活动条目保持幂等成功;接口仍要求携带 `version` 查询参数。
## 4. 公司知识介绍语义
### 4.1 学生读取与回退
```http
GET /api/v1/onboarding/companies/10/knowledge
Authorization: Bearer <studentAccessToken>
```
响应:
```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 <companyAccessToken>
```
未配置时:
```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 <companyAccessToken>
{
"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` 时不要复用旧请求体。

11303
docs/openapi-rc.json

File diff suppressed because it is too large

198
docs/salary-calculator-api.md

@ -0,0 +1,198 @@
# 薪资计算器 API(N05)
> Base URL:`/api/v1`
> 认证:`Authorization: Bearer <access-token>`
> 角色:当前登录用户必须具备 `STUDENT` 角色。
> 发布状态(2026-10-08):政策核验白名单默认为空;种子数据已入库不代表已获准发布。默认不提供城市参数或新计算,本人历史查询/删除仍保持原有角色及归属限制。
## 1. 能力边界
- 输入税前月薪,估算个人五险一金、大病固定费和当月个税后的到手工资。
- 采用居民个人综合所得累计预扣法;`monthIndex` 表示当前工资口径下的累计计税月份数,而不是自然月编号。
- 支持用户覆盖社保基数、公积金基数和个人比例。
- 城市政策以地级行政区、直辖市和省直辖县级市为最小单元,不拆分到区县。
- `dataLevel=CITY` 表示城市级参数;`dataLevel=PROVINCE_FALLBACK` 表示由于公开城市级资料不足而使用省级回退参数,结果精度较低。
- 所有结果均为估算值,实际以工资单、当地政策和税务申报为准。
## 2. 查询城市列表
`GET /salary-calculator/cities`
仅返回启用且“城市编码 + 当前政策版本”均命中发布白名单的摘要,按省份编码和城市编码排序。默认返回 `200`,`data=[]`。以下非空响应仅为契约示例,不代表这些政策已上线。
响应示例:
```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)
```
## 9. 政策核验与发布门禁
### 默认关闭
`app.salary-publication.approved-policies` 是城市编码到已核验政策版本的精确映射,默认空映射。`enabled=true`、`dataLevel=CITY`、种子数量或功能测试通过,均不等于政策已核验。V53 保留原字节,不修改种子或已有历史快照,也不新增迁移。
### 白名单配置
仅在完成核验和发布审批后,由部署人员通过外部 Spring 配置提供映射。例如下列内容只展示配置格式,不可直接照抄到生产:
```yaml
app:
salary-publication:
approved-policies:
"110000": "已核验记录对应的实际政策版本"
```
城市编码必须为六位数字,版本不能为空或包含首尾空白;非法配置启动失败。城市和数据库当前版本都必须精确匹配,版本变更后旧白名单不会自动批准新版本。不提供全量开放或按 CITY/PROVINCE_FALLBACK 批量开放的开关。
测试类显式配置的种子版本只用于算法、响应契约、并发与回滚验证,不可作为上线授权或政策准确性证据。生产默认配置没有任何批准项。
### 前端行为
- 城市列表默认 `HTTP 200 / code=200 / data=[]`;前端显示“薪资估算暂未开放”,不要自行回退到未经核验的本地参数。
- 未发布城市的详情和新计算返回 `HTTP 404 / code=10601`,使用既有固定错误文案,不泄露政策参数。
- 相同幂等键的历史重放也经过发布门禁;不能利用 POST 重放绕过关闭状态。
- 只有已发布政策才会进入原有版本冲突检查;未发布时不返回最新政策版本信息。
### 历史兼容
本人历史列表、详情和删除接口不受发布白名单影响,继续执行 STUDENT 角色与记录归属检查。历史快照仅表示当时的估算结果,不证明其政策准确,不能将其当成当前政策使用。
### 核验记录要求
每个拟开放城市必须补齐官方来源地址、发布主体、适用地区、有效期、社保和公积金基数与比例、固定费用、核验人和核验日期,并记录与数据库 `policyVersion` 的对应关系。省级回退数据还需核实适用性并明确展示估算限制。更正已提交政策应使用后续新增迁移,不改写 V1–V53。
当前 359 条种子的政策准确性尚未由本次测试证明,因此本轮不添加任何生产批准项;N03/N04 不受此门禁影响。合并、部署及部署后三中间件黑盒验收另行执行。

275
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 <accessToken>`,身份只从 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 <accessToken>
```
`categoryId` 可省略:
```http
GET /api/v1/skills/word-cloud?industryId=1&occupationId=2
```
成功响应为 `ApiResponse<SkillCloudResponse>`。`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 <accessToken>
```
匹配集合严格取当前筛选结果的 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<SkillMatchResponse>`:
```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 <accessToken>
```
学习路径只消费个人匹配结果中的 `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`。

79
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<br>复用条件说明: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:来源、覆盖、导入与责任边界中不得出现未决占位或延期确认语句。

77
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) print('paths:', len(d['paths']))
assert len(d['paths']) == 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'] 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 # 统计 operation 总数并校验 V43 + N07 + S02 + S03 + Phase A 计数 220
operations = sum(len(methods) for methods in d['paths'].values()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) print('operations:', operations)
assert operations == 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'] _select = paths['/auth/identity/select']['post']
@ -718,6 +718,79 @@ print(" Offer advance endpoints all present")
print(" 26 operations all present") print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)") 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) data = json.dumps(d)
assert '"" : "#/components/schemas/' not in data, "Found empty ref key" assert '"" : "#/components/schemas/' not in data, "Found empty ref key"
print("No empty ref keys found") print("No empty ref keys found")

13
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_REQUIRED(10401, "identity selection is required"),
IDENTITY_SELECTION_CONFLICT(10402, "identity selection conflict"), 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 int code;
private final String message; private final String message;

10
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.MATCH_REQUIRED_FOR_CONVERSATION,
ErrorCode.OFFER_COMPARE_NOT_OWNER) ErrorCode.OFFER_COMPARE_NOT_OWNER)
.put(HttpStatus.NOT_FOUND, .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_ATTEMPT_NOT_FOUND,
ErrorCode.ABILITY_RESULT_NOT_FOUND, ErrorCode.ABILITY_RESULT_NOT_FOUND,
ErrorCode.ABILITY_TYPE_NOT_FOUND, ErrorCode.ABILITY_TYPE_NOT_FOUND,
@ -146,6 +149,13 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.USER_NOT_FOUND, ErrorCode.USER_NOT_FOUND,
ErrorCode.VERIFICATION_NOT_FOUND) ErrorCode.VERIFICATION_NOT_FOUND)
.put(HttpStatus.CONFLICT, .put(HttpStatus.CONFLICT,
ErrorCode.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.PASSWORD_STATE_CONFLICT,
ErrorCode.IDENTITY_SELECTION_CONFLICT, ErrorCode.IDENTITY_SELECTION_CONFLICT,
ErrorCode.ABILITY_ATTEMPT_INVALID_STATE, ErrorCode.ABILITY_ATTEMPT_INVALID_STATE,

15
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java

@ -1,6 +1,7 @@
package com.mydreamjob.common.exception; package com.mydreamjob.common.exception;
import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.salarycalculator.SalaryPolicyVersionConflictException;
import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.common.logging.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException; import jakarta.validation.ConstraintViolationException;
@ -39,6 +40,20 @@ public class GlobalExceptionHandler {
private final RequestIdAccessor requestIdAccessor; private final RequestIdAccessor requestIdAccessor;
private final ErrorHttpStatusRegistry errorHttpStatusRegistry; private final ErrorHttpStatusRegistry errorHttpStatusRegistry;
public record SalaryPolicyConflictDetails(String latestPolicyVersion,
java.time.LocalDate policyEffectiveDate) {}
@ExceptionHandler(SalaryPolicyVersionConflictException.class)
public ResponseEntity<ApiResponse<SalaryPolicyConflictDetails>> handleSalaryPolicyConflict(
SalaryPolicyVersionConflictException ex, HttpServletRequest request) {
ErrorCode code = ErrorCode.SALARY_POLICY_VERSION_CONFLICT;
ApiResponse<SalaryPolicyConflictDetails> 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) @ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleConstraintViolation( public ResponseEntity<ApiResponse<Void>> handleConstraintViolation(
ConstraintViolationException ex, HttpServletRequest request) { ConstraintViolationException ex, HttpServletRequest request) {

3
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.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.dataexport.PersonalDataExportCleanupService; import com.mydreamjob.dataexport.PersonalDataExportCleanupService;
import com.mydreamjob.salarycalculator.SalaryHistoryCleanupService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.orm.ObjectOptimisticLockingFailureException;
@ -35,6 +36,7 @@ public class AccountDeletionCompletionWorker {
private final AuditService auditService; private final AuditService auditService;
private final AccountDeletionPhoneHasher phoneHasher; private final AccountDeletionPhoneHasher phoneHasher;
private final PersonalDataExportCleanupService dataExportCleanupService; private final PersonalDataExportCleanupService dataExportCleanupService;
private final SalaryHistoryCleanupService salaryHistoryCleanupService;
private final AuthSessionService authSessionService; private final AuthSessionService authSessionService;
private final Clock clock; private final Clock clock;
@ -68,6 +70,7 @@ public class AccountDeletionCompletionWorker {
rec.getUserId(), AuthSessionService.REASON_ACCOUNT_DELETED, capturedNow); rec.getUserId(), AuthSessionService.REASON_ACCOUNT_DELETED, capturedNow);
// 注销完成:清理该用户的个人导出文件并作废未完成任务 // 注销完成:清理该用户的个人导出文件并作废未完成任务
dataExportCleanupService.purgeForDeletedUser(rec.getUserId()); dataExportCleanupService.purgeForDeletedUser(rec.getUserId());
salaryHistoryCleanupService.purgeForDeletedUser(rec.getUserId());
// 完成 + 脱敏审计各写一次 // 完成 + 脱敏审计各写一次
auditService.log(userId, "ACCOUNT_DELETION_COMPLETED", "ACCOUNT_DELETION", auditService.log(userId, "ACCOUNT_DELETION_COMPLETED", "ACCOUNT_DELETION",
rec.getId(), "account deletion completed; userId=" + userId rec.getId(), "account deletion completed; userId=" + userId

63
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;
}

74
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<ApiResponse<CompanyKnowledgeView>> 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<ApiResponse<CompanyKnowledgeView>> 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<ApiResponse<CompanyKnowledgeOverviewResponse>> 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");
}
}

12
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<CompanyKnowledge, Long> {
Optional<CompanyKnowledge> findByCompanyId(Long companyId);
}

412
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<Object[]> 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<Object[]> 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<Object[]> 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<String, Integer> buildRatingDistribution(Long companyId) {
Map<String, Integer> distribution = new LinkedHashMap<>();
for (int rating = 1; rating <= 5; rating++) {
distribution.put("rating" + rating, 0);
}
List<Object[]> 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<String> buildTopTags(Long companyId) {
Map<String, Long> counts = new HashMap<>();
List<String> 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<String> 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<String> 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<String, ReviewSummaryView.DimensionSummaryView> buildDimensionSummary(
Long companyId) {
Map<String, Object[]> rowsByCode = new HashMap<>();
List<Object[]> 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<String, ReviewSummaryView.DimensionSummaryView> 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) {
}
}

120
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<ApiResponse<EffectiveChecklistResponse>> 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<ApiResponse<ProgressUpdateResponse>> 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<ApiResponse<List<CompanyChecklistItemView>>> listCompanyChecklistItems(
HttpServletRequest request) {
Long userId = resolveUserId();
List<CompanyChecklistItemView> data =
onboardingChecklistService.listCompanyChecklistItems(userId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "创建本企业自定义清单项", description = "支持 idempotencyKey 重放与冲突检测")
@PostMapping("/company/checklist-items")
public ResponseEntity<ApiResponse<CompanyChecklistItemView>> 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<ApiResponse<CompanyChecklistItemView>> 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<ApiResponse<CompanyChecklistItemView>> 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");
}
}

95
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;
}

39
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<OnboardingChecklistItem, Long> {
@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<OnboardingChecklistItem> 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<OnboardingChecklistItem> findCompanyItemsForUpdate(@Param("companyId") Long companyId);
}

561
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<String> 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<OnboardingChecklistItem> items = sortedItems(
checklistItemRepository.findEffectiveChecklistItems(companyId));
List<StudentOnboardingProgress> 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<OnboardingChecklistItem> 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<StudentOnboardingProgress> 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<CompanyChecklistItemView> 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<OnboardingChecklistItem> 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<OnboardingChecklistItem> ownedCompanyItems(
List<OnboardingChecklistItem> 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<OnboardingChecklistItem> sortedItems(List<OnboardingChecklistItem> items) {
if (items == null || items.isEmpty()) {
return List.of();
}
List<OnboardingChecklistItem> 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<StudentOnboardingProgress> loadProgress(
Long userId, List<OnboardingChecklistItem> items) {
if (items.isEmpty()) {
return List.of();
}
List<Long> 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<OnboardingChecklistItem> items,
List<StudentOnboardingProgress> progressRows) {
Map<Long, StudentOnboardingProgress> progressByItemId = progressRows.stream()
.collect(Collectors.toMap(
StudentOnboardingProgress::getItemId,
Function.identity(),
(first, ignored) -> first));
List<ChecklistItemView> 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<OnboardingChecklistItem> items,
List<StudentOnboardingProgress> 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<OnboardingChecklistItem> items,
List<StudentOnboardingProgress> 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);
}
}
}

64
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;
}

19
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<StudentOnboardingProgress, Long> {
List<StudentOnboardingProgress> findByStudentUserIdAndItemIdIn(
Long studentUserId, Collection<Long> itemIds);
Optional<StudentOnboardingProgress> findByStudentUserIdAndItemId(
Long studentUserId, Long itemId);
}

28
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;
}

34
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;
}

41
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;
}

24
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;
}

53
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;
}
}

21
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;
}

24
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;
}

23
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<ChecklistItemView> items;
}

15
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;
}

23
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;
}

37
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<String, Integer> ratingDistribution;
private List<String> topTags;
private Map<String, DimensionSummaryView> dimensionSummary;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class DimensionSummaryView {
private Double averageScore;
private long sampleCount;
private String dataState;
}
}

18
mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java

@ -42,4 +42,22 @@ public interface JobReviewDimensionScoreRepository extends JpaRepository<JobRevi
GROUP BY d.dimensionCode GROUP BY d.dimensionCode
""") """)
List<Object[]> aggregatePublishedByJobId(@Param("jobId") Long jobId); List<Object[]> 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<Object[]> aggregatePublishedDimensionsByCompanyId(@Param("companyId") Long companyId);
} }

33
mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java

@ -14,6 +14,39 @@ import java.util.Optional;
@Repository @Repository
public interface JobReviewRepository extends JpaRepository<JobReview, Long> { public interface JobReviewRepository extends JpaRepository<JobReview, Long> {
/** 企业公开评价汇总:一次查询统计全部岗位的 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<Object[]> 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<Object[]> 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<String> findPublishedTagsByCompanyId(@Param("companyId") Long companyId);
Optional<JobReview> findByJobIdAndStudentUserId(Long jobId, Long studentUserId); Optional<JobReview> findByJobIdAndStudentUserId(Long jobId, Long studentUserId);

91
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;未发布政策返回 404/10601,幂等重放不可绕过发布门禁;本人历史查询不受影响")
@PostMapping("/calculations")
public ResponseEntity<ApiResponse<SalaryCalculationResponse>> 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<ApiResponse<PageResponse<SalaryHistorySummaryResponse>>> 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<ApiResponse<SalaryHistoryDetailResponse>> 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<ApiResponse<Void>> 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");
}
}

262
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<TaxBracket> 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) {
}
}

86
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;
}

23
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<SalaryCalculationHistory, Long> {
Page<SalaryCalculationHistory> findByStudentUserIdOrderByCreatedAtDescIdDesc(
Long studentUserId, Pageable pageable);
Optional<SalaryCalculationHistory> findByIdAndStudentUserId(Long id, Long studentUserId);
Optional<SalaryCalculationHistory> findByStudentUserIdAndIdempotencyKey(
Long studentUserId, String idempotencyKey);
long deleteByStudentUserId(Long studentUserId);
}

19
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;
}

53
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;
}

190
mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java

@ -0,0 +1,190 @@
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;
private final SalaryPolicyPublicationProperties publication;
@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);
SalaryCityPolicy policy = cityPolicyRepository.findByCityCodeAndEnabledTrue(request.getCityCode())
.orElseThrow(() -> new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND));
requirePublished(policy.getCityCode(), policy.getPolicyVersion());
if (idempotencyKey != null) {
var existing = historyRepository.findByStudentUserIdAndIdempotencyKey(userId, idempotencyKey);
if (existing.isPresent()) {
requirePublished(existing.get().getCityCode(), existing.get().getPolicyVersion());
return replay(existing.get(), requestHash);
}
}
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 void requirePublished(String cityCode, String policyVersion) {
if (!publication.isPublished(cityCode, policyVersion)) {
throw new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND);
}
}
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<String, Object> inputSnapshot(SalaryCalculationRequest request) {
Map<String, Object> 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<String, Object> policySnapshot(
SalaryCityPolicy policy, SalaryCalculationResult result) {
Map<String, Object> 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);
}
}
}

97
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;
}

64
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<ApiResponse<List<SalaryCitySummaryResponse>>> listCities(
HttpServletRequest request) {
Long userId = resolveUserId();
return ResponseEntity.ok(ApiResponse.ok(
cityPolicyService.listCities(userId), getRequestId(request)));
}
@Operation(summary = "查询城市默认参数", description = "仅 STUDENT;未发布或未启用政策返回 404/10601,不返回参数")
@GetMapping("/cities/{cityCode}")
public ResponseEntity<ApiResponse<SalaryCityDetailResponse>> 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");
}
}

15
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<SalaryCityPolicy, String> {
List<SalaryCityPolicy> findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc();
Optional<SalaryCityPolicy> findByCityCodeAndEnabledTrue(String cityCode);
}

86
mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyService.java

@ -0,0 +1,86 @@
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;
private final SalaryPolicyPublicationProperties publication;
@Transactional(readOnly = true)
public List<SalaryCitySummaryResponse> listCities(Long userId) {
requireStudent(userId);
return cityPolicyRepository.findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc()
.stream()
.filter(policy -> publication.isPublished(policy.getCityCode(), policy.getPolicyVersion()))
.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));
if (!publication.isPublished(policy.getCityCode(), policy.getPolicyVersion())) {
throw 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);
}
}
}

6
mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryDataLevel.java

@ -0,0 +1,6 @@
package com.mydreamjob.salarycalculator;
public enum SalaryDataLevel {
CITY,
PROVINCE_FALLBACK
}

18
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);
}
}

106
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<SalaryHistorySummaryResponse> 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<SalaryCalculationHistory> 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);
}
}
}

22
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;
}

33
mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyPublicationProperties.java

@ -0,0 +1,33 @@
package com.mydreamjob.salarycalculator;
import jakarta.annotation.PostConstruct;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/** 发布白名单为空时失败关闭;enabled/dataLevel 不表示政策已核验。 */
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "app.salary-publication")
public class SalaryPolicyPublicationProperties {
private Map<String, String> approvedPolicies = new LinkedHashMap<>();
@PostConstruct
void validate() {
if (approvedPolicies == null || approvedPolicies.entrySet().stream().anyMatch(entry ->
entry.getKey() == null || !entry.getKey().matches("[0-9]{6}")
|| entry.getValue() == null || entry.getValue().isBlank()
|| !entry.getValue().equals(entry.getValue().trim()))) {
throw new IllegalStateException("invalid salary publication allowlist");
}
}
public boolean isPublished(String cityCode, String policyVersion) {
return cityCode != null && policyVersion != null && approvedPolicies != null
&& policyVersion.equals(approvedPolicies.get(cityCode));
}
}

23
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;
}
}

68
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);
}
}
}

53
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;
}

51
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;
}

19
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;
}

34
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;
}

23
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;
}

31
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;
}

25
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;
}

207
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<ApiResponse<SkillCloudResponse>> 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<ApiResponse<SkillMatchResponse>> 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<ApiResponse<SkillLearningPathResponse>> 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");
}
}

160
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<SkillCloudSkillProjection> 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<SkillCloudSkillProjection> projections = validatedCategoryId == null
? occupationSkillRepository.findSkillProjectionsByOccupationId(validatedOccupationId)
: occupationSkillRepository.findSkillProjectionsByOccupationIdAndCategoryId(
validatedOccupationId, validatedCategoryId);
List<SkillCloudSkillProjection> sortedProjections = projections.stream()
.sorted(SKILL_ORDER)
.toList();
List<SkillCloudItem> 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 <T extends Comparable<? super T>> 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);
}
}

95
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<SkillCloudItem> missingSkills = matchResponse.missingSkills();
List<SkillLearningPathItem> 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;
}
}
}
}

152
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<SkillCloudItem> topSkills = cloud.items().subList(0, actualTopSize);
Set<String> studentSkills = loadStudentSkills(studentUserId);
Map<Long, List<SkillAlias>> aliasesBySkillId = loadAliases(topSkills);
List<SkillCloudItem> matchedSkills = new ArrayList<>();
List<SkillCloudItem> 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<String> 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<String> parsed = objectMapper.readValue(
profile.getSkillsJson(),
new TypeReference<List<String>>() {}
);
if (parsed == null || parsed.isEmpty()) {
return Set.of();
}
Set<String> 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<Long, List<SkillAlias>> loadAliases(List<SkillCloudItem> topSkills) {
Collection<Long> skillIds = topSkills.stream()
.map(SkillCloudItem::skillId)
.filter(java.util.Objects::nonNull)
.toList();
if (skillIds.isEmpty()) {
return Map.of();
}
Map<Long, List<SkillAlias>> 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<String> studentSkills,
List<SkillAlias> 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<String> studentSkills, String candidate) {
if (candidate == null) {
return false;
}
String normalized = skillNameNormalizer.normalize(candidate);
return !normalized.isBlank() && studentSkills.contains(normalized);
}
}

28
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();
}
}

62
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;
}

56
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;
}

41
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;
}

50
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;
}

47
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;
}

56
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;
}

50
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;
}

20
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
) {
}

8
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
) {
}

15
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<SkillCloudItem> items
) {
public SkillCloudResponse {
items = List.copyOf(items);
}
}

21
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
) {
}

11
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
) {
}

12
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<SkillLearningPathItem> items
) {
public SkillLearningPathResponse {
items = List.copyOf(items);
}
}

16
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<SkillCloudItem> matchedSkills,
List<SkillCloudItem> missingSkills
) {
public SkillMatchResponse {
matchedSkills = List.copyOf(matchedSkills);
missingSkills = List.copyOf(missingSkills);
}
}

73
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<OccupationSkill, Long> {
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<SkillCloudSkillProjection> 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<SkillCloudSkillProjection> findSkillProjectionsByOccupationIdAndCategoryId(
@Param("occupationId") Long occupationId,
@Param("categoryId") Long categoryId);
}

15
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<SkillAlias, Long> {
Optional<SkillAlias> findByNormalizedAlias(String normalizedAlias);
List<SkillAlias> findAllBySkillIdInOrderByIdAsc(Collection<Long> skillIds);
}

13
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<SkillCategory, Long> {
boolean existsByIdAndOccupationId(Long id, Long occupationId);
Optional<SkillCategory> findByIdAndOccupationId(Long id, Long occupationId);
}

7
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<SkillIndustry, Long> {
}

13
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<SkillOccupation, Long> {
boolean existsByIdAndIndustryId(Long id, Long industryId);
Optional<SkillOccupation> findByIdAndIndustryId(Long id, Long industryId);
}

15
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<Skill, Long> {
Optional<Skill> findByCode(String code);
List<Skill> findAllByIdInOrderByIdAsc(Collection<Long> ids);
}

7
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<SkillSourceVersion, Long> {
}

120
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='职位技能关系与热度';

413
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);

12
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));

57
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='企业知识介绍';

31
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);

69
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='薪资计算历史快照';

368
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)
;

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

@ -442,19 +442,28 @@ class CompanyResumeDownloadIntegrationTest {
long pathCount = paths.size(); long pathCount = paths.size();
long opCount = 0; long opCount = 0;
long modulePaths = 0;
long moduleOperations = 0;
var it = paths.fieldNames(); var it = paths.fieldNames();
while (it.hasNext()) { 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(); var f = ops.fieldNames();
while (f.hasNext()) { while (f.hasNext()) {
if (java.util.Set.of("get", "post", "put", "delete", "patch") if (java.util.Set.of("get", "post", "put", "delete", "patch")
.contains(f.next())) { .contains(f.next())) {
opCount++; opCount++;
if (module) moduleOperations++;
} }
} }
} }
assertThat(pathCount).as("统一账号身份选择新增一个路径后应为 183 paths").isEqualTo(183); assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13);
assertThat(opCount).as("统一账号身份选择新增一个 operation 后应为 221 operations").isEqualTo(221); 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"); var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse();

6
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/password/change","/auth/login","/auth/password/login","/auth/password/reset",
"/auth/refresh","/auth/sms/send","/auth/company/register"); "/auth/refresh","/auth/sms/send","/auth/company/register");
int denied=0; int denied=0;
int moduleDenied=0;
var paths=doc.path("paths").fields(); var paths=doc.path("paths").fields();
while(paths.hasNext()) { while(paths.hasNext()) {
var path=paths.next(); var path=paths.next();
@ -204,9 +205,12 @@ class UnifiedRegistrationIntegrationTest {
.header("Authorization","Bearer "+token).contentType(MediaType.APPLICATION_JSON).content("{}")) .header("Authorization","Bearer "+token).contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(10401)); .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(10401));
denied++; 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"), java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-identity-gate-result.json"),
"{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}"); "{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}");
} }

19
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java

@ -74,7 +74,7 @@ class CandidateScoringRefreshClaimTokenTest {
} }
private Long newTask() { private Long newTask() {
requestService.requestRefresh(sid, LocalDateTime.now()); requestService.requestRefresh(sid, SAME_INSTANT.minusSeconds(1));
return jdbcTemplate.queryForObject( return jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid); "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(); sid = userRepository.save(u).getId();
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); 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()); period = String.format("%04d-%02d", n.getYear(), n.getMonthValue());
} }
@ -197,7 +198,7 @@ class CandidateScoringRefreshClaimTokenTest {
String token1 = claimToken(t1); String token1 = claimToken(t1);
Long sid2 = newStudent(); Long sid2 = newStudent();
requestService.requestRefresh(sid2, LocalDateTime.now()); requestService.requestRefresh(sid2, SAME_INSTANT.minusSeconds(1));
Long t2 = jdbcTemplate.queryForObject( Long t2 = jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid2); "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid2);
CandidateScoringRefreshTaskExecutor.ClaimedTask c2 = taskExecutor.claim(t2, SAME_INSTANT); CandidateScoringRefreshTaskExecutor.ClaimedTask c2 = taskExecutor.claim(t2, SAME_INSTANT);
@ -237,7 +238,7 @@ class CandidateScoringRefreshClaimTokenTest {
assertNotNull(a); assertNotNull(a);
String ta = claimToken(taskId); 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)); assertEquals("RETRY", status(taskId));
assertNull(claimToken(taskId), "回收后应清空 claim_token"); assertNull(claimToken(taskId), "回收后应清空 claim_token");
@ -258,7 +259,7 @@ class CandidateScoringRefreshClaimTokenTest {
// PENDING 回退 // PENDING 回退
Long sid2 = newStudent(); Long sid2 = newStudent();
requestService.requestRefresh(sid2, LocalDateTime.now()); requestService.requestRefresh(sid2, SAME_INSTANT.minusSeconds(1));
Long t2 = jdbcTemplate.queryForObject( Long t2 = jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid2); "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid2);
CandidateScoringRefreshTaskExecutor.ClaimedTask c2 = taskExecutor.claim(t2, SAME_INSTANT); CandidateScoringRefreshTaskExecutor.ClaimedTask c2 = taskExecutor.claim(t2, SAME_INSTANT);
@ -270,7 +271,7 @@ class CandidateScoringRefreshClaimTokenTest {
// RETRY // RETRY
Long sid3 = newStudent(); Long sid3 = newStudent();
requestService.requestRefresh(sid3, LocalDateTime.now()); requestService.requestRefresh(sid3, SAME_INSTANT.minusSeconds(1));
Long t3 = jdbcTemplate.queryForObject( Long t3 = jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid3); "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid3);
CandidateScoringRefreshTaskExecutor.ClaimedTask c3 = taskExecutor.claim(t3, SAME_INSTANT); CandidateScoringRefreshTaskExecutor.ClaimedTask c3 = taskExecutor.claim(t3, SAME_INSTANT);
@ -310,7 +311,7 @@ class CandidateScoringRefreshClaimTokenTest {
assertTrue(token.matches(".*[0-9a-f]{8}-.*"), "token 必须是随机 UUID 形态而非 ID 拼接"); assertTrue(token.matches(".*[0-9a-f]{8}-.*"), "token 必须是随机 UUID 形态而非 ID 拼接");
// 再领一次必须得到不同 token(不可复用)—— 用独立学生,避免 (student,period) 合并 // 再领一次必须得到不同 token(不可复用)—— 用独立学生,避免 (student,period) 合并
Long otherStudent = newStudent(); Long otherStudent = newStudent();
requestService.requestRefresh(otherStudent, LocalDateTime.now()); requestService.requestRefresh(otherStudent, SAME_INSTANT.minusSeconds(1));
Long t2 = jdbcTemplate.queryForObject( Long t2 = jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", "SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?",
Long.class, otherStudent); Long.class, otherStudent);
@ -410,8 +411,8 @@ class CandidateScoringRefreshClaimTokenTest {
return userRepository.save(u).getId(); return userRepository.save(u).getId();
} }
/** R01-C 阶段二:本类会以**当前 period** 物化快照。残留行会进入同库中其它测试 /** R01-C 阶段二:本类会物化固定期间的快照。残留行会进入同库中其它测试
* 对"当前期间"的总体统计(如 Fairness 的 populationSize),造成顺序相关误失败。 * 的总体统计(如 Fairness 的 populationSize),造成顺序相关误失败。
* 此处按本类**独占手机号前缀**解析出精确 id,再按外键顺序删除;不使用 deleteAll。 */ * 此处按本类**独占手机号前缀**解析出精确 id,再按外键顺序删除;不使用 deleteAll。 */
@AfterEach @AfterEach
void cleanupOwnFixture() { void cleanupOwnFixture() {

14
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)); assertEquals(HttpStatus.BAD_REQUEST, new ErrorHttpStatusRegistry().resolve(123456789));
} }
private static final java.util.Map<String, HttpStatus> 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) { private HttpStatus expectedStatus(ErrorCode errorCode) {
String name = errorCode.name(); String name = errorCode.name();
if (MODULE_ADDITIONS.containsKey(name)) return MODULE_ADDITIONS.get(name);
if (FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN; if (FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN;
if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND; if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND;
if (CONFLICT.contains(name)) return HttpStatus.CONFLICT; if (CONFLICT.contains(name)) return HttpStatus.CONFLICT;

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

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

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

@ -96,8 +96,22 @@ class GlobalExceptionHandlerMappingParityTest {
private final GlobalExceptionHandler handler = private final GlobalExceptionHandler handler =
new GlobalExceptionHandler(new RequestIdAccessor(), registry); new GlobalExceptionHandler(new RequestIdAccessor(), registry);
private static final java.util.Map<String, HttpStatus> 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) { private static HttpStatus baselineStatus(ErrorCode errorCode) {
String name = errorCode.name(); 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_REQUIRED")) return HttpStatus.FORBIDDEN;
if (name.equals("IDENTITY_SELECTION_CONFLICT")) return HttpStatus.CONFLICT; if (name.equals("IDENTITY_SELECTION_CONFLICT")) return HttpStatus.CONFLICT;
@ -118,8 +132,8 @@ class GlobalExceptionHandlerMappingParityTest {
@Test @Test
@DisplayName("B2:显式映射集合与重构前快照逐项相等") @DisplayName("B2:显式映射集合与重构前快照逐项相等")
void explicitMappingSetEqualsPreRefactorSnapshot() { void explicitMappingSetEqualsPreRefactorSnapshot() {
assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(123); assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(133);
assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(122); assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(132);
for (ErrorCode errorCode : ErrorCode.values()) { for (ErrorCode errorCode : ErrorCode.values()) {
HttpStatus expected = baselineStatus(errorCode); HttpStatus expected = baselineStatus(errorCode);

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save