Compare commits

...

12 Commits

Author SHA1 Message Date
Zhang Shun 439c25be9c feat(auth): 支持验证码登录后首次设置密码 2 weeks ago
Zhang Shun fa6555d9e5 feat(company): add own job management list 2 weeks ago
Zhang Shun c3ca89a186 Merge remote-tracking branch 'origin/release/identity-selection-20260929' into fix/mysql57-module-migrations-20261009 3 weeks ago
Zhang Shun f966dfa291 fix: support MySQL 5.7 module migrations 3 weeks ago
zhangshun 43739f42ed Merge pull request 'feat: integrate skill cloud onboarding and salary modules' (#2) from integration/lxq-n03-n05-20261008 into release/identity-selection-20260929 3 weeks ago
Zhang Shun 0609b9422a fix: gate salary policies behind verified publication allowlist 3 weeks ago
Zhang Shun 5f82c125ed feat: integrate skill cloud onboarding and salary modules 3 weeks ago
Zhang Shun b1d9dd2d31 feat: integrate Aliyun SMS verification sender 4 weeks ago
Zhang Shun 80969c65c3 fix: apply CORS allowlist beneath servlet context path 4 weeks ago
Zhang Shun 32fb6470a4 feat: unify account registration and first identity selection 4 weeks ago
Zhang Shun 9c14abd498 feat: gate recruiting operations on company verification 4 weeks ago
Zhang Shun bcea6b0295 fix: make SMS verification limits atomic 4 weeks ago
  1. 77
      changes/n03-skill-cloud/data/source-manifest.md
  2. 1
      docs/backend-development-roadmap.md
  3. 73
      docs/first-password-setup-implementation.md
  4. 4
      docs/frontend-integration.md
  5. 434
      docs/onboarding-api.md
  6. 11394
      docs/openapi-rc.json
  7. 163
      docs/phase-b-company-verification-gate-implementation-report.md
  8. 136
      docs/phase-b-company-verification-gate-independent-verification-20260923.md
  9. 198
      docs/salary-calculator-api.md
  10. 275
      docs/skill-cloud-api.md
  11. 79
      docs/skill-taxonomy-sources.md
  12. 49
      docs/unified-registration-identity-selection-design.md
  13. 159
      docs/unified-registration-identity-selection-implementation-report.md
  14. 104
      docs/verify_openapi.py
  15. 6
      mydreamjob-server/pom.xml
  16. 17
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java
  17. 16
      mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java
  18. 15
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java
  19. 2
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java
  20. 5
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java
  21. 23
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsFailedAttemptRecorder.java
  22. 6
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java
  23. 12
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java
  24. 1
      mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java
  25. 6
      mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java
  26. 4
      mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java
  27. 11
      mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupRequest.java
  28. 6
      mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupResponse.java
  29. 76
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java
  30. 31
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionRequest.java
  31. 8
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionResponse.java
  32. 104
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionService.java
  33. 5
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java
  34. 18
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallenge.java
  35. 16
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallengeRepository.java
  36. 73
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupService.java
  37. 57
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/AliyunSmsCodeSender.java
  38. 35
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/AliyunSmsProperties.java
  39. 3
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java
  40. 26
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java
  41. 2
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java
  42. 18
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java
  43. 6
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java
  44. 3
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java
  45. 2
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java
  46. 4
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java
  47. 18
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  48. 14
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java
  49. 15
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
  50. 24
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java
  51. 10
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java
  52. 41
      mydreamjob-server/src/main/java/com/mydreamjob/company/VerifiedCompanyGuard.java
  53. 90
      mydreamjob-server/src/main/java/com/mydreamjob/config/MySql57MigrationConfiguration.java
  54. 17
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  55. 67
      mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
  56. 3
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java
  57. 28
      mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java
  58. 25
      mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewService.java
  59. 16
      mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreService.java
  60. 30
      mydreamjob-server/src/main/java/com/mydreamjob/job/CompanyJobManagementSpecification.java
  61. 4
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java
  62. 4
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java
  63. 63
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java
  64. 30
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/CompanyManagedJobItem.java
  65. 14
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java
  66. 16
      mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationService.java
  67. 25
      mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java
  68. 63
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledge.java
  69. 74
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeController.java
  70. 12
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeRepository.java
  71. 412
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java
  72. 120
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java
  73. 95
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItem.java
  74. 39
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java
  75. 561
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java
  76. 64
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgress.java
  77. 19
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgressRepository.java
  78. 28
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ChecklistItemView.java
  79. 34
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemCreateRequest.java
  80. 41
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemUpdateRequest.java
  81. 24
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemView.java
  82. 53
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java
  83. 21
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeUpdateRequest.java
  84. 24
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeView.java
  85. 23
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/EffectiveChecklistResponse.java
  86. 15
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateRequest.java
  87. 23
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateResponse.java
  88. 37
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ReviewSummaryView.java
  89. 8
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java
  90. 18
      mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java
  91. 33
      mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java
  92. 91
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java
  93. 262
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationEngine.java
  94. 86
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistory.java
  95. 23
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistoryRepository.java
  96. 19
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationInput.java
  97. 53
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationResult.java
  98. 190
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java
  99. 97
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicy.java
  100. 64
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyController.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核对结果 | 下一动作 |
|---|---|---|
| N03/N04/N05 模块集成(2026-10-08 增补) | 隔离集成版本全量 `mvn clean verify` 3224/3224,原仓库转入复验 227/227,F/E/S 均为 0;真实 MySQL 独立功能 9/9、迁移及运行时接口集合 2/2;N05 并发幂等阻断已修复;OpenAPI 196/239 | 待独立提交,随后安排部署和前后端联调;薪资政策数据发布前仍需来源复核。下方历史规划原文保留,不作为当前模块进度依据 |
| 核心招聘与账户能力 | 已有代码与历史验收记录;本轮未重跑验收 | 按清单C01–C08做一期集成回归 |
| V39–V42 | 已核对提交55e3975、2698d06、5cc2c86、96b3113;不再标为待Git检查点 | 保留回归,不重复开发企业回复/发现/邀约 |
| V43 | 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(cf39737) | N01 已关闭版本内小样本补集泄露;30/300/3000 查询预算通过,EXPLAIN 扩容风险保留观察 |

73
docs/first-password-setup-implementation.md

@ -0,0 +1,73 @@
# 短信登录后首次设置密码:实施与联调指令
本批面向后端与前端开发人员,实现首次设置引导。设置密码后,短信与密码登录继续并存;不新增必须设置密码才能使用业务的门禁。
## 1. 登录与密码状态
在 POST /auth/login、POST /auth/password/login 和 GET /auth/me 中读取 passwordConfigured。它由 password_hash 是否存在派生,不增加 users 状态列。
无密码账号只有在 login 用途短信验证成功后,才获得 passwordSetupToken 和 passwordSetupExpiresIn=600。凭证不是 JWT;使用安全随机数生成 32 字节,数据库只保存带 password-setup 域前缀的 HMAC 摘要。
凭证绑定 userId、sessionId 和固定到期时间。密码登录、刷新、身份选择都不会签发设置凭证;刷新不能延长有效期。当前会话退出或被撤销后,凭证不可使用。
## 2. 请求与响应
前置地址为既有 /api/v1。新接口 POST /auth/password/setup 必须携带当前 Access Token。
```json
{"passwordSetupToken":"短信登录返回的一次性凭证","newPassword":"用户输入的新密码"}
```
成功返回现有 ApiResponse 包装,data 含 passwordConfigured=true、reauthenticationRequired=false、identitySelectionRequired、accessToken、refreshToken、expiresIn。有效期沿用既有认证配置与契约,不写死为一小时。
前端应先替换 Access/Refresh Token 并清除设置凭证,再根据 identitySelectionRequired 进入身份选择或原角色首页。已有密码不重复弹设置页。凭证过期或响应丢失时,重新短信登录并读取密码状态;不得反复提交创建操作。
参数/密码策略错误为 400,无效会话或设置凭证为 401,已有密码冲突为 409,限流为 429,安全依赖不可用为 503。不得把 SQL、密码、令牌或凭证摘要返回给客户端。
## 3. 后端事务顺序
新增 V54 password_setup_challenges,不修改 V1–V53。核查 MySQL 5.7/8.4 和 H2 的兼容性。
先锁定用户并校验 active/注销状态,再锁定当前服务端会话记录,串行处理并发登出;检查操作限流与尚无密码,复用现有密码策略。数据库条件更新必须同时匹配用户、会话、摘要、未消费、未过期,且更新行数恰好为 1。
随后 BCrypt 保存密码及 passwordChangedAt,flush 用户,撤销全部旧会话,建立新会话,并写固定、脱敏审计。上述操作使用同一数据库事务;任何步骤失败均回滚。Redis 限流计数不宣称自动回滚。
未选身份账号只新增放行 POST /auth/password/setup,继续保留其他业务门禁。客户端不传 userId、role 或 sessionId。
## 4. 兼容与库存
保留 /auth/password/set 的 password-set 短信用途与成功后重新认证语义;保留修改、找回密码及原有登录、刷新、登出接口。设置成功不能覆盖企业认证或账号角色。
静态 OpenAPI 与运行时均增加一个路径、一个操作,目标 198 路径、241 操作。接口库存测试随真实新增接口更新;不降低原有身份门禁断言。
## 5. 验收步骤
用明确假号段准备无密码账号,经真实验证码消费逻辑短信登录,检查未设置状态和凭证有效期。首次设置后检查 BCrypt 匹配、新会话可用、旧 Access/Refresh Token 被撤销;再次短信登录不发设置凭证,密码登录也应成功。
测试跨用户、跨会话、匿名、过期、已消费、弱密码和安全依赖异常。两个并发请求同时使用凭证,只允许一个成功;审计或会话创建失败应回滚密码、消费状态、会话变更。
同一套断言在 H2 和真实 MySQL 8.4 执行,并运行 MySQL 5.7 迁移及认证关联回归。记录真实 F/E/S 与 OpenAPI 结果后再安排独立复验、提交和部署。
当前文档描述本地实现;测试结果以本轮实际报告为准,不表示已上线或已完成前端接入。
## 6. 本轮验证结果与交付边界
2026-10-12:认证关联回归执行 68 项,Failures=0、Errors=0、Skipped=0,覆盖旧密码接口、刷新、会话、统一注册、企业注册,以及真实 MySQL 5.7/8.4 迁移和运行时接口库存。
最终补充数据库异常安全响应与 DTO 脱敏后,重新编译最新源码并执行 H2 和真实 MySQL 8.4 的首次设置密码测试,共 20 项,Failures=0、Errors=0、Skipped=0;定向 mvn verify 成功并生成最新 JAR。两组测试存在重叠,不合计为 88 个独立用例。
静态 OpenAPI 检查通过:198 路径、241 操作。历史 V1–V53 无内容修改;新增 V54。git diff --check 通过,暂存区为空。
2026-10-12 补充全量与最新 JAR 验收:`mvn -o clean verify` 成功,3278 项,Failures=0、Errors=0、Skipped=0。首次全量发现 MySql57MigrationConfigurationTest 仍断言 53 个迁移,与新增 V54 不一致;仅更新计数为 54,定向 4/4 通过后重跑全量成功。全量日志位于本机临时目录 password-setup-full-verify-final.log。
验收对象为上述 clean verify 生成的 mydreamjob-server-0.0.1-SNAPSHOT.jar,SHA256:`635EB9F268771D266EED8965FCD39F03F9B24E8B7219945980FB54037D303822`。
使用独立、一次性 MySQL 8.4、Redis 7.4、RabbitMQ 3.13 容器与该 JAR 做真实 HTTP 黑盒,应用及管理健康检查均为 200,Flyway 达到 V54。使用假号段测试账号,短信发送通过 Mock sender,但验证码生成、数据库消费、Redis 限流及认证会话均执行真实实现;验证码从隔离数据库读取,没有调用运营商。
黑盒通过:无密码账号短信登录返回首次设置凭证;弱密码 400、匿名设置 401;设置成功返回新会话且无需重新登录;旧 Access 与 Refresh 均 401,新 Access 可用;再次使用设置凭证 409;设置后选择 STUDENT 成功;密码登录成功;再次发送验证码并短信登录成功,返回 passwordConfigured=true 且不再返回设置凭证。
数据库逐项证据:测试用户 1 行、STUDENT 主角色与 user_roles 各 1 条、BCrypt 格式密码与 password_changed_at 均已保存;设置凭证 1 条且已消费;两条短信验证码均已消费。四个 RabbitMQ 队列(短信、通知、审计、通知死信)ready/unacknowledged 全为 0。应用日志扫描未发现测试手机号、明文测试密码或 JWT/Bearer。
静态及运行时 OpenAPI 均为 198 路径、241 操作,方法/路径集合完全一致。黑盒脚本最初使用 PowerShell 属性集合的 .Count 导致误报,改用数组计数复核后通过。首次 JAR 启动遇到默认管理端口 8081 与前端 Metro 冲突,将本次管理端口设为 18081 后启动成功;应用端口为 18080。
验收后已停止本次 JAR,并删除三个一次性容器及其测试数据;13307、16379、25672、18080、18081 无本次残留监听。前端 Metro 保持运行。本批未暂存、提交、推送或部署;真实短信与前端设置密码页面联调仍未执行,不代表线上已有此接口。

4
docs/frontend-integration.md

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

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` 时不要复用旧请求体。

11394
docs/openapi-rc.json

File diff suppressed because it is too large

163
docs/phase-b-company-verification-gate-implementation-report.md

@ -0,0 +1,163 @@
# Phase B:已认证企业业务门禁实施报告
日期:2026-09-23
范围:仅 Phase B;未暂存、未提交、未推送、未部署。
## 结论
Phase B 门禁已在服务层实施并通过全量验证。`mvn test` 与 `mvn clean verify` 均为 **2880 / 0 failures / 0 errors / 0 skipped**;真实 MySQL/Testcontainers 用例实际运行。OpenAPI 检查为 **182 paths / 220 operations,All checks passed**。API 路径和 operation 集合未改,`docs/openapi-rc.json` 无差异。
本轮没有启动应用部署或连接生产 RabbitMQ。拒绝路径的事件/通知调用通过测试中的 mock `never()` 断言验证;本机 RabbitMQ 容器当时为停止状态,因此不报告未经测量的 broker 队列深度。没有数据库迁移,也没有新增业务状态字段。
## 1. 开工核查与基线
- 分支:`master`
- HEAD:`8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`(Phase A 已交付基线)
- 暂存区:空;`git diff --check`:exit 0
- 工作区:除本轮明确列出的改动外,原有历史未跟踪文件全部保留;未运行 `reset`、`restore`、`checkout`、`clean` 或任何目录级 `git add`。
- `git status` 曾将 `CandidateOverviewStatusPolicy.java` 与 `JobPreferenceScoreCalculator.java` 标成修改;逐文件 `git hash-object` 与 HEAD blob 相同,且 `git diff --quiet`,确认是 Windows stat 缓存假阳性,内容未改。
- 迁移库存最高为 **V46**;V1–V46 未改、无 V47。
- 上线健康接口可见版本仅为 `0.0.1-SNAPSHOT`,不能据此映射出精确线上 commit。Phase B 未部署,线上版本未改变。
- Docker 可用;最终全量验证中的 MySQL/Testcontainers 测试真实执行,Skipped=0。
## 2. 权威设计和门禁语义
以 `docs/company-onboarding-admin-user-management-design.md` §7 及独立评审为准:企业认证事实只读 `companies.verified`;`users.is_verified` 不用于企业认证判断。
检查顺序保持为:现有认证/活动账号校验 → `user_roles` 的 COMPANY 身份 → 当前企业资料存在及 owner 归属 → 仅对受限招聘业务检查 `companies.verified=true`。认证门禁不能取代任何角色、归属、资源可见性判断。资源查询前优先校验调用者所属企业;按资源授权的入口仍保留其原归属检查,避免跨企业数据探测。
未认证、审核中、已拒绝均按 `companies.verified=false` 统一拒绝受限操作,固定响应 `COMPANY_NOT_VERIFIED` / code **5004** / HTTP **403** / 文案 `company not verified`。该错误码与已有数字码无冲突,HTTP 状态集中登记在 `ErrorHttpStatusRegistry`,没有在 Controller 中复制异常映射。
## 3. 端点库存与允许/禁止矩阵
逐个检查了 Controller 映射及其 Service 路径。下表中“COMPANY”表示现有 Controller/服务中的 COMPANY 身份要求仍保留;“own/资源归属”表示原企业 owner、岗位、投递或参与者校验仍执行。受限入口在服务端、候选人/Offer/面试等敏感查询或副作用之前执行认证门禁。
| 接口范围(HTTP method + path) | Controller → Service | Phase B 结果及既有校验 |
|---|---|---|
| `POST /jobs`;`PUT /jobs/{jobId}`;`PATCH /jobs/{jobId}/status` | `JobController` → `JobService` | 仍要求 COMPANY 和本企业岗位归属。创建 DRAFT、编辑自己的 DRAFT 允许;已发布内容编辑以及向 `PUBLISHED` 转换要求认证。将自己已发布岗位转为 `CLOSED` 仍允许,保留安全下架能力和旧状态语义。跨企业岗位仍按旧归属错误拒绝。公开 `GET /jobs`、`GET /jobs/search`、`GET /jobs/public/{jobId}`、`GET /companies/{companyId}/jobs` 不改变。 |
| `GET /company/applications`;`PATCH /applications/{id}/status`;`GET /company/candidates`;`GET /company/candidates/{applicationId}`;`GET /company/candidates/{applicationId}/resume`;`GET /applications/{id}/timeline`(企业分支) | `ApplicationController` → `ApplicationService` / `CompanyResumeAccessPolicy` | COMPANY、公司/岗位归属与既有可见性校验保留。企业认证先于敏感投递/候选人查询;下载门禁先于简历存储读取。拒绝时不读文件;学生本人的投递/timeline 分支不加企业门禁。 |
| `GET /company/candidates/{applicationId}/scoring`;`GET /company/scoring/dashboard/{overview,job-rankings,job-rankings/{jobId},candidate-rankings,candidate-rankings/{applicationId},trend}` | `CandidateScoringController`、`CompanyScoringDashboardController` → scoring services | 企业评分/候选人评分属于候选人敏感数据,所有公司入口要求 verified;原公司归属与管理员分支保持不变。 |
| `GET/POST/PUT/DELETE /applications/{applicationId}/feedback` | `CandidateFeedbackController` → `CandidateFeedbackService` | 企业侧 feedback 先校验公司归属及认证,再访问投递或写反馈;学生自己的反馈路径/权限保持原语义。 |
| `GET /companies/me/jobs/{jobId}/talent-recommendations`;`POST /companies/me/jobs/{jobId}/talent-invitations`;`GET /companies/me/talent-invitations`;`GET /companies/me/talent-invitations/{invitationId}` | `TalentDiscoveryController`、`TalentInvitationController` → respective services | 企业身份、岗位/邀请归属后要求认证;门禁先于候选人匹配、资料加载及邀请写入/通知。`/profile/me/talent-discovery` 与求职者邀请列表/决定接口仍为求职者自身功能。 |
| `POST/GET /applications/{applicationId}/interviews`;`GET /interviews/me`;`GET/PUT /interviews/{interviewId}`;`POST /interviews/{interviewId}/cancel`、`/complete`;`GET /interviews/{interviewId}/timeline` | `InterviewController` → `InterviewService` | 企业安排、修改、取消、完成及查看面试时先检查企业认证及原面试/投递归属;求职者查看和回应自己的面试保留原路径与行为。 |
| `PUT/GET /interviews/{interviewId}/score`;`POST /interviews/{interviewId}/score/submit`;`GET /interviews/{interviewId}/score/history` | `InterviewScoreController` → `InterviewScoreService` | 企业侧评分读写要求认证,并保留面试参与者/归属检查;求职者侧不被企业门禁误伤。 |
| `POST/GET /offers`;`GET/PUT /offers/{id}`;`PATCH /offers/{id}/status`;`GET /offers/{id}/timeline`;`POST /offers/{id}/remind`;`GET/POST /offers/{id}/counter-offers`;`PATCH /offers/{id}/counter-offers/{coId}/accept`、`/reject`;`GET /offers/statistics` | `OfferController` → `OfferService` | 对公司发起、读取、修改、发送/流转 Offer 及统计入口,在查询/写入/提醒副作用之前验证公司。原 application/offer/counter-offer 所有权检查保留。`GET /offers/compare` 为求职者接口,不改。 |
| `GET/PUT /companies/me/jobs/{jobId}/recommendation-profile`;`GET/PUT /companies/me/match-rule` | recommendation、`MatchRuleController` → services | 公司侧候选人推荐/匹配配置要求 verified,并保留 owner 校验;`/profile/me/match-rule` 等求职者配置不改。 |
| `PUT /applications/{id}/hr-suitability-score`;`GET /applications/{id}/match`;`POST /applications/{id}/conversation`;`GET/POST /conversations`;`GET /conversations/{id}`、`/{id}/messages`;`POST /conversations/messages`、`/{id}/read`、`/{conversationId}/messages/{messageId}/recall` | match、conversation、`MessagingController` → services | 企业候选人匹配、发起/读取招聘会话和消息均需 verified。会话成员、投递和消息归属校验保留;求职者自己的候选兴趣分/匹配路径不变。消息列表读取还使用明确加载 `user_roles` 的查询,避免 detached lazy roles 导致角色分支错误。 |
| `GET /notifications`、`/unread-count`;`POST /notifications/{id}/read`、`/read-all`(企业主体) | `NotificationController` → `NotificationService` | 企业用户侧通知查看/读状态操作要求认证,拒绝先于通知列表读取或读状态写入;其他主体语义保留。系统 worker/事件生产路径不套用户发起的企业门禁。 |
| `GET/POST/PUT /companies/me`;`GET /companies/me/onboarding`;`POST/GET /companies/me/verification` | `CompanyController`、`CompanyVerificationController` → company/onboarding/verification services | 未认证企业仍可查改自己的资料、查询聚合状态、提交和查询自己的认证申请。身份及 owner 关系照旧验证;认证审核状态机未改。 |
### 明确未加门禁的入口
- 公开岗位、公司资料和公开评价查询没有候选人隐私或企业招聘副作用,保持公开行为。
- `CompanyReviewManagementController` 的本企业公开岗位评价/回复用于企业声誉运营,不读取候选人敏感资料,也不是招聘动作;按 Phase B 设计范围不设认证门禁,既有公司归属照旧。
- `ApplicationMatchController` 中求职者的 candidate-interest-score、求职者岗位匹配与个人设置仍按原 STUDENT 路径处理。
- `OfferExpiryScheduler`/expiry service、匹配通知重试、候选评分 refresh worker、邀请过期清理和通知消费是系统维护/既有任务,不是未认证企业发起的招聘请求;逐一检查没有从这些后台入口调用受限用户写 API,因此不加企业认证门禁,避免误伤既有系统处理。
## 4. 实施内容和精确文件范围
新增服务层共用规则 `mydreamjob-server/src/main/java/com/mydreamjob/company/VerifiedCompanyGuard.java`。它只依据传入的 `Company.verified` 判定;角色、活动状态、公司归属和资源归属仍由现有 service/guard 流程负责。
修改的生产文件(20 个):
- `application/ApplicationService.java`、`application/CompanyResumeAccessPolicy.java`
- `auth/UserRepository.java`
- `bidirectionalmatch/BidirectionalMatchService.java`、`MatchConversationService.java`、`MatchRuleService.java`
- `candidatescore/CandidateScoringEngineService.java`、`candidatescore/dashboard/CompanyScoringDashboardService.java`
- `common/exception/ErrorCode.java`、`ErrorHttpStatusRegistry.java`
- `feedback/CandidateFeedbackService.java`
- `interview/InterviewService.java`、`interviewscore/InterviewScoreService.java`
- `job/JobService.java`
- `messaging/MessagingService.java`、`notification/NotificationService.java`
- `offer/OfferService.java`
- `recommendation/JobRecommendationService.java`
- `talentdiscovery/TalentDiscoveryService.java`、`talentinvitation/TalentInvitationService.java`
新增测试类(3 个):
- `phaseb/PhaseBVerifiedCompanyGateContractTest.java`:静态端点—Service 覆盖映射,并锁定认证只看 `companies.verified`。
- `phaseb/VerifiedCompanyGuardTest.java`:verified / false / null / company missing 与固定业务码和 403 映射。
- `phaseb/VerifiedCompanyGatePersistenceIntegrationTest.java`:真实 HTTP `PATCH /jobs/{id}/status` 对 UNVERIFIED、PENDING、REJECTED 均返回 403/code 5004,并确认 DRAFT 仍未被发布;verified 企业发布成功。
另有既有 service/controller/persistence 测试被扩展:求职者、企业、管理员认证角色及旧行为回归;简历读取计数与租户边界;Job 发布;Offer、Interview、TalentInvitation 的拒绝副作用;feedback、matching、messaging、notification、scoring、resume、interview-score 的企业测试夹具现在明确使用已认证 Company,避免旧测试把默认 `verified=false` fixture 误当已认证企业。R01-B/R01-C/R01-E 错误映射和消息查询预算测试只调整了 Phase B 新码映射/现有 eager-role 读取所增加的固定查询基线。
被修改的既有测试文件(27 个):
- `application/ApplicationServiceTest.java`、`CompanyResumeAccessPolicyTest.java`、`CompanyResumeDownloadIntegrationTest.java`
- `bidirectionalmatch/BidirectionalMatchPersistenceIntegrationTest.java`、`BidirectionalMatchServiceTest.java`、`MatchConversationServiceTest.java`、`MatchRuleServiceTest.java`
- `candidatescore/CandidateScoringClockConsistencyTest.java`、`CandidateScoringEngineServiceTest.java`
- `common/exception/GlobalExceptionHandlerStatusContractTest.java`
- `common/r01b/ErrorHttpStatusRegistryTest.java`、`GlobalExceptionHandlerMappingParityTest.java`、`R01BIndependentHttpMappingParityTest.java`
- `common/r01e/R01EIndependentCrossDatabaseVerificationTest.java`、`R01EQueryBudgetAcceptanceTest.java`
- `feedback/CandidateFeedbackRefreshRegistrationTest.java`、`CandidateFeedbackServiceTest.java`
- `interview/InterviewServiceTest.java`
- `interviewscore/InterviewScoreControllerTest.java`、`InterviewScorePersistenceIntegrationTest.java`、`InterviewScoreServiceTest.java`
- `job/JobServiceTest.java`
- `messaging/MessageRecallHttpIntegrationTest.java`、`MessagingServiceTest.java`
- `notification/NotificationServiceTest.java`
- `offer/OfferServiceTest.java`
- `talentinvitation/TalentInvitationServiceTest.java`
没有修改 Controller mappings、DTO、`pom.xml`、Flyway、V1–V46、OpenAPI 文件、短信服务、Phase A 注册/验证码/Token/企业认证审核流程,也没有任何 Phase C 用户/角色管理代码。
## 5. TDD 失败证据与测试覆盖
- 先增加 `JobServiceTest.transitionStatus_unverifiedCompanyCannotPublish`,在尚无门禁时失败:期望 `BusinessException`,旧实现没有抛出。修复前失败日志保存在系统临时目录,不加入仓库。
- 对简历策略做单点变异(移除认证判断)后,`CompanyResumeAccessPolicyTest` 新增负向用例失败:未认证时仍访问 `applicationRepository.findById`,与“门禁先于候选人/简历数据读取”相违。源码恢复后,变异目标测试转绿;变异失败日志保存在系统临时目录。
- HTTP 集成覆盖 UNVERIFIED/PENDING/REJECTED 统一 403 及 verified 可发布。跨企业资源仍由原 service ownership tests 和既有集成回归覆盖。
- 简历测试包含 storage read probe,拒绝路径 `READ_CALLS=0`。Job 拒绝后状态仍 DRAFT。Offer、Interview、TalentInvitation 的未认证路径对投递/岗位/候选人/offer/interview/invitation/history/audit/notification/刷新等依赖均断言 `never()`,无业务写入或通知触发。
- 无效 Token、匿名、STUDENT、ADMIN 的 401/403 使用现有 SecurityFilterChain/Controller 回归;Phase B 没有放开匿名路由,也没有改 SecurityConfig。
本轮测试驱动过程中的非产品问题:部分旧单测严格 Mockito stubbing 仍 stub 旧的 `findById`;用户/角色集合在 service 离开事务后为 lazy,导致企业身份分支不能可靠判定。通过更新这些测试的 stub,并新增 `UserRepository.findByIdWithRoles` 明确加载 `user_roles` 修复;没有降低断言,也没有删角色检查。OpenAPI 初次调用时使用了不存在的 `docs/verify/_openapi.py` 路径;依据仓库文件清单改用 `docs/verify_openapi.py` 后全部通过,未改脚本。
## 6. 验证结果
| 检查 | 结果 |
|---|---|
| Phase B 定向(Contract + Guard + persistence + Job/Application/Resume/Offer/Interview/Invitation 及关联服务) | **189/189,F=0 E=0 S=0**;另外最新版 persistence HTTP 类 **2/2** 单独通过 |
| 全量 `mvn test` | **2880/2880,F=0 E=0 S=0,BUILD SUCCESS** |
| 最终 `mvn clean verify` | **2880/2880,F=0 E=0 S=0,BUILD SUCCESS**;MySQL/Testcontainers 实测执行 |
| `python ..\docs\verify_openapi.py`(从 server 模块目录) | **182 paths / 220 operations,All checks passed** |
| 数据库 schema/migration | V1–V46 无差异;无迁移需求;无 V47 |
| OpenAPI/API surface | Controller 路径和 operation 集合未改变;`docs/openapi-rc.json` 无差异 |
Phase B 新增用例共 **12**,全套从 2868 增至 2880。Docker 中 MySQL 8.4/Testcontainers 用例真实执行,Skipped=0。Hikari 在 Testcontainers 销毁/重建连接时输出连接已关闭的校验 WARN;两轮全量均以 0 errors 成功完成。
## 7. 副作用、安全与已知观测
- 认证事实静态断言确认 Guard 只读取 `Company.verified`,不读取 `User.isVerified`。
- 租户 A 即使已认证仍须通过原 owner/资源关系校验;新门禁不构成跨企业授权。服务先验证企业/权限,再查候选人敏感资源;简历 policy 在 `read(storagePath)` 之前失败关闭。
- 拒绝请求不会改变 DRAFT/Offer/Interview/Invitation 等业务记录;未调用 audit、notification、message/event publisher 或 scoring refresh 依赖。没有真实 RabbitMQ 运行,因此不声称 broker queue depth 被独立读取;拒绝路径的 publisher 无交互由测试逐项锁定。
- 新增代码和报告未包含凭据、JWT、密码、真实手机号、数据库连接秘密、容器动态口令或本机路径;测试数据使用非真实 fixture。修复前日志留在临时目录,未加入 Git。
- 没有通过设置 `verified=true` 绕过生产流程;“verified”测试场景仅在测试 fixture 中按显式场景构造,以验证认证企业原行为。
## 8. Git 结束状态
- HEAD 仍为 `8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`,分支仍为 `master`。
- 暂存区为空,`git diff --check` exit 0;没有提交、推送、部署或合并。
- 新增生产组件和 Phase B 测试及本报告仍未跟踪;所有此前历史未跟踪文件原样保留。
- 工作区中 20 个已跟踪生产文件有 Phase B 改动;额外两个显示为修改的既有文件已通过 blob hash 证实内容与 HEAD 完全一致。
- 最终核查无 Java/Maven 残留进程,8080/8081 无监听;未停止或删除 Docker 容器/镜像。
Phase C 管理员用户与角色管理未进入;Phase B 未部署。下一步是独立代码复验,不是提交或部署。
## 9. 两项独立复验阻断修复(2026-09-23)
独立复验发现岗位发布门禁发生在岗位 ID/归属查询之后,可能通过业务码与 HTTP 状态差异探测跨企业岗位;另发现 `VerifiedCompanyGatePersistenceIntegrationTest` 使用全局 `TestAuthTokens` 数据源注册表,在组合运行时偶发无法签发会话。
- `JobService.transitionStatus` 对目标 `PUBLISHED` 先调用 `requireVerifiedCompany`,再查询岗位;未认证公司的有效 ID、其他公司 ID 和不存在 ID 均先得到固定认证拒绝,不触发岗位查询。
- 新增 `JobRepository.findByIdAndCompanyId`,并用于企业岗位读取、修改和状态操作。跨企业岗位和不存在岗位都按 `JOB_NOT_FOUND`/404 处理;`JobController.getJob` 改为单次归属限定查询,避免泄露资源存在性。
- 保留设计允许的未认证企业自有 DRAFT 岗位查看与编辑;已认证企业仍必须通过原企业归属条件,不获得跨企业访问权限。
- `VerifiedCompanyGatePersistenceIntegrationTest` 不再调用静态全局 `TestAuthTokens`,改由当前 Spring 测试上下文的真实 `AuthSessionService` 创建会话,Token 和会话记录落在当前事务数据源中,事务结束回滚,不依赖其他测试上下文的注册/销毁顺序。
- 先加 HTTP 回归再改生产代码时,新测试复现了原差异:未认证发布外企业岗位得到 400、读取/修改外企业岗位得到 400,而目标契约为认证门禁 403 或与不存在资源一致的 404。更新后的 HTTP 集成类 4/4 通过;岗位与认证边界组合 33/33 通过;原 10 类定向组合现为 **191/191**,F=0/E=0/S=0。
- 两条既有跨企业岗位 HTTP 断言改为校验 404/`JOB_NOT_FOUND`;夹具明确设成 VERIFIED,确保发布测试实际走到资源归属门禁而不是被未认证门禁短路。
修复后验证:
| 检查 | 结果 |
|---|---|
| `mvn test` | **2882/2882,F=0/E=0/S=0,BUILD SUCCESS** |
| `mvn clean verify` | **2882/2882,F=0/E=0/S=0,BUILD SUCCESS**;Testcontainers 真实执行 |
| `python ..\docs\verify_openapi.py` | **182 paths / 220 operations,All checks passed** |
两项阻断已修复并验证。本轮仍未暂存、提交、推送或部署;Phase B 后续仍需独立复验结论确认后才能进入相应检查点。

136
docs/phase-b-company-verification-gate-independent-verification-20260923.md

@ -0,0 +1,136 @@
# Phase B「已认证企业业务门禁」独立代码复验与真实 MySQL 黑盒验收
日期:2026-09-23
范围:Phase B 独立代码复验、缺陷修复后回归、MySQL 8.4 + HTTP 黑盒
结论:**通过;未暂存、未提交、未推送、未部署**
## 1. 结论摘要
独立复核确认,招聘类受限操作由 Service 层的共用企业认证门禁保护,判定依据为 `companies.verified`;角色、公司归属和资源归属仍分别执行。补充修复两处复验阻断后,新增真实 MySQL 8.4 + 随机端口 HTTP 黑盒测试通过;Phase B 定向回归 **208/208**,全量 `mvn test` 与 `mvn clean verify` 各 **2884/2884,Failures=0、Errors=0、Skipped=0**。OpenAPI 检查 **182 paths / 220 operations,All checks passed**。
本轮没有连接或修改服务器生产数据库,没有部署服务,没有修改迁移文件或 `docs/openapi-rc.json`。测试中 Redis 使用 mock,RabbitMQ listener 被关闭,因此本文不把本轮结论扩展为真实 Redis/RabbitMQ 黑盒验收。
## 2. Git 与工作区边界
- 分支:`master`
- HEAD:`8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`
- upstream:`8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`;ahead/behind `0/0`
- 暂存区:空;`git diff --check`:exit 0(仅有 Git 的 LF/CRLF 提示)
- 本地工作区存在既有未暂存改动与历史未跟踪材料。没有清理、覆盖、暂存或删除这些文件。
- Phase B 现有差异包含 **22 个已跟踪生产文件 + 新增 `VerifiedCompanyGuard.java`**,以及 **28 个已跟踪测试文件 + 4 个 Phase B 新测试类**。本报告和 Phase B 实施报告均未跟踪;本轮没有执行任何 Git 写入操作。
- V1–V46 未修改;未新增 V47;`docs/openapi-rc.json` 未修改。
- 未进入 Phase C。
## 3. 独立审查范围与规则
复核了 Phase B 实施报告、企业入驻设计及复审文档、`VerifiedCompanyGuard`、服务层调用点、岗位资源归属查询、错误码映射、认证边界测试,以及候选人/简历下载入口。
规则核对结果:
1. 未认证、审核中、已拒绝企业均以 `companies.verified=false` 拒绝受限招聘业务;未使用 `users.is_verified` 代替企业认证。
2. 认证门禁在 Service 业务路径执行,不只依赖 Controller;角色/企业归属/资源可见性仍单独保留。
3. 企业资料与认证申请、入驻状态等允许操作未被本轮认证门禁一并封锁。
4. 错误使用固定 `COMPANY_NOT_VERIFIED`(HTTP 403),经统一 `ErrorHttpStatusRegistry` 映射,不在 Controller 复制转换逻辑。
5. 岗位访问必须按企业范围查找;对外企业岗位记录不存在与不属于当前企业均按相同 `JOB_NOT_FOUND` / 404 处理。
## 4. 复验阻断及修复
### 4.1 岗位资源边界与检查顺序
复验期间确认岗位接口需要同时满足两个条件:未认证企业发布岗位时应被认证门禁拒绝;已认证企业也不能借岗位 ID 探测或操作其他企业的岗位。已调整为:
- 发布状态转换在读取目标岗位前先执行认证门禁,使未认证企业对目标 ID 不获得差异化响应;
- 已认证企业的岗位读取/更新使用 `findByIdAndCompanyId` 范围查询;跨企业与不存在资源统一返回 `JOB_NOT_FOUND` / 404;
- 企业自己的草稿维护及既有关闭行为按设计保留。
覆盖测试:`JobServiceTest`、`AuthenticationBoundaryIntegrationTest`,以及本报告新增的真实 MySQL + HTTP 黑盒。
### 4.2 持久化认证会话测试接线
原持久化测试通过静态 Token 测试助手建会话,不能确保会话写入当前 Testcontainers 数据源。测试现改为从 Spring 上下文调用 `AuthSessionService.createSession`,令令牌与当前测试数据库一致,避免测试仅凭伪造/错库 Token 得出授权结论。
## 5. 新增真实 MySQL + HTTP 黑盒
新增测试:`mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGateMySqlBlackboxTest.java`。
- Testcontainers 使用真实 `mysql:8.4`,创建隔离数据库 `phase_b_gate_blackbox`,正式执行 Flyway,并使用 Hibernate `ddl-auto=validate`。
- Spring Boot 完整应用在随机端口启动;通过 HTTP socket 请求 `/api/v1/...`,覆盖 Spring Security、Controller、Service、Repository 和真实 MySQL。
- 使用精确假号段夹具,测试结束按本类登记的主键清理;容器随后由 Testcontainers 回收。
结果 **2/2,F=0、E=0、S=0**:
1. UNVERIFIED、PENDING、REJECTED 三种 `companies.verified=false` 企业对岗位发布均返回 403 与 `COMPANY_NOT_VERIFIED`。每个拒绝前后比较 15 张表的稳定行快照,并核对岗位仍为 DRAFT。
2. 未认证企业读取候选人列表、候选人详情与简历下载均被拒绝。简历存储 `read()` 计数为 0;响应非 PDF;15 张表拒绝前后逐行快照相同。
3. VERIFIED 企业能够发布自己的岗位;不能读取、更新或发布另一企业岗位。外企业资源与不存在资源均返回相同 404/`JOB_NOT_FOUND`,跨企业拒绝前后数据库快照无变化。
4. 用户夹具的 `users.is_verified` 被有意设为 true,而公司认证状态分别按测试构造。这证明被测门禁取决于 `companies.verified`,而不是用户通用 verified 字段。
此黑盒未启动真实 Redis 或 RabbitMQ:RedisTemplate 为 mock,RabbitMQ listener 显式禁用。拒绝请求的数据库快照覆盖 `notifications`、`audit_logs`、`messages` 等表;但这不能替代 broker 队列深度检查,也不能证明真实 Redis 可用性。
## 6. 测试结果
| 验证 | 结果 |
|---|---:|
| Phase B 定向(门禁/角色/岗位/投递/简历/Offer/面试/邀约/消息等 12 类) | **208/208,F=0、E=0、S=0** |
| 真实 MySQL + HTTP 黑盒 | **2/2,F=0、E=0、S=0**(包含在定向结果内) |
| `mvn test` | **2884/2884,F=0、E=0、S=0** |
| `mvn clean verify` | **2884/2884,F=0、E=0、S=0** |
| 全量 Surefire XML | 277 个测试类;2884 项;F/E/S 均为 0 |
| `python ..\docs\verify_openapi.py` | **182 paths / 220 operations,All checks passed** |
MySQL/Testcontainers 在本机 Docker 上真实运行;新增黑盒类标记 `disabledWithoutDocker=false`,不会在 Docker 缺失时静默跳过。全量测试 `Skipped=0`。
## 7. 测试驱动器自身问题记录
初次黑盒执行出现 HTTP 404,是 `TestRestTemplate` 测试 URL 使用相对路径造成的 harness 错误;随后改为以 `@LocalServerPort` 构造 `127.0.0.1` 绝对 URL,黑盒 **2/2** 通过。测试初次编译还因 `DirtiesContext` 导入包路径错误失败,改为 Spring Test 正确包后通过。两项均为测试代码接线问题,不是产品缺陷;修正后定向与全量测试均已重跑通过。
## 8. 运行环境收尾
检查时 Docker 容器列表为空;8080/8081 无监听;无残留 `java` 或 `mvn` 进程。黑盒使用随机端口,并未占用上述端口。临时 MySQL 为 Testcontainers 隔离容器,没有触及本机开发库或服务器库。
## 9. 未执行/不在本轮结论内
- 未做生产服务器部署或真实公网 MySQL/Redis/RabbitMQ 验收。
- 未做运行时 OpenAPI 路径集合与静态 OpenAPI 的双集合比较;本轮运行的是仓库 OpenAPI 检查器,且 `docs/openapi-rc.json` 保持未改。
- 未覆盖到的每一个招聘入口仍以既有 Phase B 结构/服务测试清单和全量回归为依据;本轮新增真实黑盒聚焦岗位发布、候选人列表/详情/简历,以及跨企业岗位访问,不能解读为每个业务端点都由真实 HTTP 重放。
- 未实现 Phase C 管理员用户与角色管理;未修改短信服务;未伪造任何真实企业认证状态。
## 10. 最终判定
**Phase B 独立代码复验及真实 MySQL + HTTP 黑盒验收通过。** 两处复验阻断已修复;定向、全量测试及 OpenAPI 检查通过。当前仍是未暂存的本地工作区状态,后续如需提交,必须另行进行提交前精确文件范围审查;本轮没有暂存、提交、推送或部署。
## 11. 2026-09-29 精确暂存与隔离验证检查点
本节追加记录最新检查点,不覆盖第 1–10 节的历史执行结果与当时仓库状态。
### 11.1 批次边界与验证来源
- 当前分支为 `feat/phase-b-company-verification-gate`;基线 HEAD 为 `bcea6b02955f20f933572e8c8f2c85b1ad05843c`,包含此前单独本地提交的短信 P0 修复。
- Phase B 暂存范围为 **57 个文件:23 个生产文件、32 个测试文件、2 份报告**,没有删除文件。未纳入迁移、`pom.xml`、OpenAPI 文件、基础设施脚本或历史验收输出。
- 共用错误码与映射测试只暂存 Phase B 的 `COMPANY_NOT_VERIFIED` 及其 403 映射,未纳入统一注册/身份选择的新增错误码和接口。
- 验证目录由 HEAD 归档加暂存补丁生成,不读取原工作区未暂存的统一注册/身份选择实现。57 个暂存文件与验证快照按 Git 换行规范化后的 blob 逐项比较,**不一致数为 0**。原工作区其他批次改动与历史未跟踪文件均保留。
### 11.2 本次失败记录及纠正
1. 初次 32 类组合运行 **928 项,F=1、E=0、S=0**。简历下载测试的 OpenAPI 数量期望误混入未提交身份选择批次的 183/221,而当前隔离快照实际为 182/220。已从暂存区移除这两行无关 hunk,使用 HEAD 的 182/220 契约;原工作区 183/221 改动仍保留。没有修改生产实现或放宽授权断言。随后 12 类定向及全量验证通过。
2. 9 月 28 日首次全量尝试未完成,跨库测试报告 1 项容器启动错误,根因链包含 Docker 命名管道不存在(`docker_engine`)。该次没有 Maven 最终汇总,不记为通过,也不推算其全量测试总数。日志保留在系统临时验证目录,不纳入提交。Docker 恢复后确认服务版本 29.6.2,以原暂存代码快照重新执行完整 `clean verify`。
### 11.3 最新验证结果
| 验证 | 实测结果 |
|---|---:|
| Phase B 12 类定向测试 | **208 项,F=0、E=0、S=0,BUILD SUCCESS** |
| Phase B 真实 MySQL + HTTP 黑盒 | **2 项,F=0、E=0、S=0**(包含在定向与全量结果内) |
| 隔离暂存快照 `mvn clean verify` | **2889 项,F=0、E=0、S=0,BUILD SUCCESS** |
| 全量 Surefire XML 独立汇总 | **281 个测试类,2889 项,F/E/S 均为 0** |
| 短信错误次数真实 MySQL 并发测试 | **1 项,F=0、E=0、S=0**(基线短信批次回归) |
| 短信限流真实 Redis 测试 | **1 项,F=0、E=0、S=0**(基线短信批次回归) |
| 静态 OpenAPI 检查器 | **182 paths / 220 operations,All checks passed** |
| Spring 测试上下文运行时 OpenAPI 与静态集合比较 | **双方均 182/220,路径差集 0,路径/HTTP 方法 operation 差集 0** |
本次全量命令使用 `-Ds03.openapi.snapshot=<临时输出目录>` 留存既有简历测试通过 MockMvc 获取的运行时文档;比较以解析后的路径与 HTTP 方法集合为准,不只比较数量。该证据来自完整 Spring 测试上下文,**不是独立 JAR 或公网部署版本的 OpenAPI 验收**。此次没有另行宣称一次新的 `mvn test` 结果;`clean verify` 已实际执行全量测试并打包成功。
### 11.4 暂存安全扫描与结论边界
暂存新增行扫描发现 2 处手机号形状的既有测试夹具字面量、1 处明确仅用于临时 Testcontainers 数据库的测试口令;均不是实际用户或服务器凭据。疑似绝对路径命中均为路由数字正则或 loopback 随机端口 HTTP URL,不是本机文件路径。未发现真实 JWT、Bearer、邮箱、BCrypt 摘要、私钥、AWS key 或服务器连接密钥;原始运行日志和容器输出不暂存。
**Phase B 精确暂存与隔离验证检查点通过。** 本节完成后仅更新并暂存本报告,尚未创建 Phase B 本地提交,未推送、未部署,未进入 Phase C。真实 MySQL HTTP 黑盒的 Redis mock 与禁用 RabbitMQ listener 边界仍如第 5 节所述;本次全量中的真实 Redis 测试不能替代 Phase B 全链路三中间件或公网验收。

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

49
docs/unified-registration-identity-selection-design.md

@ -0,0 +1,49 @@
# 统一手机号注册与首次身份选择设计
## 范围与基线
本设计面向后端实施、独立复验和前端联调。采用一次性身份选择,不实现双身份切换、管理员公开注册、历史账号转身份或线上部署。
开工分支 master,HEAD 8c9429108cbd5d85e5245f6c404b2172bf6dd7f3。暂存区空;已有 Phase B、验证码 P0、岗位等未提交变更及 290 个历史未跟踪条目须保留。
## 权威库存
- AuthService.mockLogin 对新手机号显式创建 STUDENT;密码登录只登录既有账号。
- User 主角色为 VARCHAR(30),user_roles 为关联表,无数据库枚举限制。最新迁移 V46,无新增字段需求。
- JwtAuthenticationFilter 只建立已认证主体,业务角色由 Service 判定;存在仅要求认证的业务接口,需要入口白名单。
- AuthSessionService.createSession/revokeAllUserSessions 使用 REQUIRED;刷新持锁顺序 users → auth_tokens,身份选择沿用该顺序。
- AuthTokenRepository 的 modifying 操作 flush 并清空 persistence context;必须先 flush 角色变化,再撤销/创建会话。
- 企业创建资料约束复用 CompanyRegistrationRequest.CompanyDetails;企业注册开关只接受字面量 true。
- 现有 /auth/company/register 保留原契约,不新增第二套角色或企业认证状态。
## 状态机和兼容
新手机号通过 login 用途短信验证 → UNASSIGNED 主角色、空业务 roles → 一次选择 STUDENT 或 COMPANY。
COMPANY 选择同时创建 verified=false 的企业资料,后续沿用 Phase A 入驻与 Phase B 招聘认证门禁。
旧三种角色不迁移。实体/数据库 STUDENT 默认值暂保留用于既有内部创建点兼容,统一注册路径显式赋值,不依赖默认授权。
未知/矛盾主角色和 roles 组合不得通过身份选择;UNASSIGNED 即使被误写业务 roles 也应被公共角色 guard 拒绝。
## 接口
- /auth/login、/auth/password/login 和 /auth/me 增加 identitySelectionRequired(主角色派生),保留其他字段和成功码。
- POST /auth/identity/select:identity 仅 STUDENT/COMPANY;COMPANY 必须提供 company,STUDENT 禁止提供 company;身份来自当前会话,客户端不能指定 userId/role/verified。
- 首次选择返回强类型响应:userId、role、identitySelectionRequired=false、companyId、onboardingStatus、新 Token 对及 expiresIn。
- 有效会话重复相同身份不修改资料或审计,返回状态和 reauthenticationRequired=true,不再次颁发凭证。失效旧会话返回 401,重新登录可恢复。
- 已选其他身份 409;企业开关关闭 503;未选择身份访问受限业务 403;非法请求 400。
## 白名单
待选身份仅允许 GET /auth/me、POST /auth/identity/select、POST /auth/logout、POST /auth/password/set/change,及既有匿名认证入口 /auth/login、/auth/password/login、/auth/password/reset、/auth/refresh、/auth/sms/send、/auth/company/register。
公开只读保持允许:GET /、/health(/子路径)、Swagger/OpenAPI、岗位公开列表/搜索/筛选/对比/评价、企业公开主页/岗位、岗位推荐分类字典。业务端口 actuator 路径保留原 404。
其余已登录业务统一拒绝(含资料、认证、投递、候选人、Offer、面试、消息、测评、通知、导出和管理员)。方法与路径同时匹配,不用 /auth/** 放行。
白名单检查由 IdentityAccessFilter 置于 JWT 验证之后的安全过滤链,在 MVC 路径/请求格式匹配之前执行,读取数据库当前身份;未知接口默认拒绝。通过禁用容器自动注册避免重复执行。公共角色 guard 同时拒绝 UNASSIGNED,避免服务调用用矛盾 roles 绕过。
## 原子事务与竞争
users 行锁 → 重新验证当前会话与账号注销状态 → 校验身份/资料/开关 → 更新角色并 flush → 建企业并 flush → 撤销全部旧会话 → 创建新会话 → 必需审计。任一失败回滚全部数据库更改,不返回 Token。
验证码错误计数独立事务及 Redis 限流不纳入该回滚范围,不改变既有 P0。
并发不同选择只允许一种提交,使用行锁后重新验证会话,防止认证过滤器校验与事务执行间的竞争。与刷新采用同一锁顺序;选择之前的会话在提交后失效。
## 验收
先保存生产修改前的失败证据,覆盖统一登录、身份选择、角色组合、企业开关、会话撤销、事务回滚和真实 MySQL 竞争。旧角色测试保留;仅更新明确依赖新手机号默认 STUDENT 的契约断言,不放宽授权断言。
H2 定向、认证关联、真实 MySQL、全量 test/clean verify、静态与运行时 OpenAPI 对齐后进入独立复验。真实短信未接通时明确受控 Mock,不冒充短信送达。

159
docs/unified-registration-identity-selection-implementation-report.md

@ -0,0 +1,159 @@
# 统一手机号账号注册与首次身份选择实施报告
## 当前状态
统一手机号账号注册与首次身份选择本地实施完成,待独立代码复验。
最终全量验证与独立 JVM 最新 JAR 黑盒均通过。没有暂存、提交、推送或部署。
## 范围与基线
项目为职来后端(项目目录 `zhilai`)。分支 master;开工 HEAD 为 `8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`。
开工已存在 Phase B、验证码 P0、岗位等未提交改动,以及 290 个历史未跟踪条目。本功能叠加于工作区,不能把全部 diff 当成本轮改动。
最新迁移 V46;角色字段是 VARCHAR,无需新增迁移。不实现双身份切换、历史账号转角色、公开管理员注册或 Phase C。
## 权威库存
原 `AuthService.mockLogin` 为新手机号自动创建 STUDENT;密码登录不负责注册。
`users.role` 与 `user_roles` 都被生产代码读取;JWT 认证以服务端有效会话为准,业务角色由 Service 校验。
`AuthSessionService` 的会话创建/撤销参与调用方事务;刷新按 users → auth_tokens 顺序加锁。
撤销 Repository modifying 操作会清空 persistence context,身份更新必须先 flush。
企业资料复用既有 `CompanyDetails` 的 Validation 约束,开关复用只接受字面量 true 的企业注册配置。
## 新注册与旧账号兼容
新手机号仍通过 `POST /auth/login` 验证 login 用途短信,创建 UNASSIGNED 主角色和空业务 roles。
登录、密码登录和 `/auth/me` 都新增派生字段 `identitySelectionRequired`,成功业务码仍为 200;旧字段不删除。
既有 STUDENT、COMPANY、ADMIN 不重置、不迁移。实体和数据库的 STUDENT 默认值保留以兼容其他明确的内部创建点,统一注册路径显式覆盖为 UNASSIGNED。
原 `/auth/company/register` 保留为兼容入口,并不强迫旧客户端立即改造。
## 首次身份选择接口
`POST /auth/identity/select` 必须使用有效 Access Token;身份和 sessionId 取自当前已验证会话。
```json
{"identity":"STUDENT"}
```
```json
{"identity":"COMPANY","company":{"name":"示例企业","industry":"互联网"}}
```
identity 仅允许 STUDENT/COMPANY;COMPANY 必须提供 company,STUDENT 禁止提供 company。
不接受客户端 userId、role、verified 或其他未知字段;嵌套企业资料也不允许非法授权字段。
首次成功返回新 Token 对、userId、role、identitySelectionRequired=false;企业额外返回 companyId 和 UNVERIFIED。
同身份重复请求只返回当前状态、reauthenticationRequired=true,不重复发 Token、修改资料或追加审计。
原会话失效后重复使用旧 Token 返回 401;客户端应采用新凭证,丢失响应时通过原登录方式重新登录。
已经选择另一种身份的账号返回 409,不能用该接口切换身份。
## 待选身份访问门禁
`IdentityAccessFilter` 只作用于 JWT 过滤器建立的已验证会话主体,在 JWT 之后、MVC 路由和参数解析之前执行。
它读取数据库当前身份,使用精确的 HTTP 方法/路径白名单,未知业务入口默认拒绝。
白名单包含身份查询/选择、登出、密码维护和原匿名认证入口,以及既有公开只读页面。
企业资料、认证申请、测评、消息、投递、候选人、岗位写操作和管理员接口不对 UNASSIGNED 放行。
容器自动注册被禁用,避免过滤器在 JWT 之前执行或重复执行。
公共 `UserRoleGuard.requireRole` 额外拒绝 UNASSIGNED,即使 user_roles 被误写成业务角色也不能通过该 guard。
旧 Controller 接线探针使用的非 JWT 测试主体不新增解析调用;生产仍只有 JWT 认证方式,未启用 Basic/Form 登录。
## 事务与锁顺序
1. Service 层再次验证 DTO。
2. 锁定 users,重新核验 active、当前会话和注销状态。
3. 确认主角色/roles 无矛盾、没有孤立企业资料;校验企业开关。
4. 更新 users.role 与唯一业务 role,saveAndFlush。
5. COMPANY 创建唯一 verified=false 企业资料并 flush。
6. 撤销全部旧会话,创建带新角色的新会话。
7. 同事务执行 MANDATORY 必需审计,固定事件 IDENTITY_SELECTED,明细只含结果。
任何异常回滚角色、企业和 Token 更改;不返回未提交的凭证。锁顺序与刷新一致,不增加跨请求缓存。
注销 PENDING/COMPLETED 拒绝选择,取消后的 NONE 可继续;禁用账号在锁内再次拒绝。
原验证码原子消费、独立错误计数事务和 Redis 限流不被修改,也不虚称这些 Redis 状态会自动回滚。
## 错误响应
新增唯一数字码:10401 身份待选/403,10402 身份冲突/409,10403 身份选择依赖不可用/503。
非法 JSON/字段/参数为原 400;匿名、失效会话和非法 Token 为原 401;企业注册开关关闭沿用原 503。
没有修改任何历史错误码数字;历史 9103 重码事实仍保留。
历史映射快照资源未改,只在契约测试中单独登记三个新增映射和相应数量。
## 修复前证据
生产修改前新增 6 个 HTTP 测试:6 run / 6 failures / 0 errors / 0 skipped。
明确复现:新账号实际为 STUDENT;待选业务请求实际 200;身份选择路径不存在。
失败证据在忽略目录 `docs/acceptance/unified-identity/`,不会进入提交。
## 实施中发现与修正
- Jackson 全局忽略未知字段,初版 userId 注入请求被接受;改为新 DTO/企业嵌套 DTO 安全拒绝未知字段。
- 初版 MVC 拦截器晚于 multipart/Content-Type 路由匹配;全端点测试定位到格式不符请求先出错,改为安全过滤链门禁。
- 新过滤器初版增加历史 Controller 接线探针的 Accessor 调用;限定为 JWT 已验证会话主体后保留旧精确调用次数断言。
- MySQL 回滚测试初版在事务代理外 stub MANDATORY 审计,导致测试装配失败;改为对代理内部 spy 注入故障,实际调用仍经过真实事务代理。
- Repository 会将故障转换为 DataAccessException,回滚测试明确验证安全业务码 10403,不要求错误穿透为原异常类型。
- 密码 HTTP 测试初版遗漏密码模块开关和 mock Redis 原子脚本返回值;补齐测试前置。真实 Redis 证据由最新 JAR 验收单独提供。
- 新增接口使旧 OpenAPI 数量断言失败;只将数量改为 183/221,保留原简历安全字段和接口断言。
- MySQL Service 并发只产生一份身份,但首次最新 JAR HTTP 并发出现 200/500:失败请求在锁内抛出 InvalidTokenException,新 Controller 未转换。补确定性测试先复现 1 run / 1 failure(expected 401 but was 500),再复用原认证安全响应返回 401;没有改变锁、角色或数据断言。
- JAR 验收脚本初版把 `/auth/sms/send` 的查询参数放进 JSON body,实测缺少 phone 返回 400;按现有 RequestParam 契约修正脚本,未修改生产短信接口。Maven exec 参数需整体引号;RabbitMQ 改用本机已缓存的官方 3.13 management 镜像,未通过反复网络拉取干扰其他服务。
## 精确本轮文件范围
新增生产文件(4):
- `auth/identity/IdentitySelectionRequest.java`
- `auth/identity/IdentitySelectionResponse.java`
- `auth/identity/IdentitySelectionService.java`
- `auth/identity/IdentityAccessFilter.java`
修改生产文件(9):`AuthService`、`User`、`PasswordAuthenticationService`、`UserRoleGuard`、`ErrorCode`、`ErrorHttpStatusRegistry`、`CompanyRegistrationRequest`、`SecurityConfig`、`AuthController`。
其中异常码文件已有 Phase B 改动,本轮只叠加 10401–10403;其他原有 P0/B/岗位文件不被本轮覆盖。
新增测试文件(4):`IdentityPolicyTest`、`UnifiedRegistrationIntegrationTest`、`IdentitySelectionMySqlTest`、`UnifiedIdentityJarAcceptanceProbe`。最后一项为手工最新 JAR 验收 main,不冒充 JUnit 数量。
修改既有测试(8):`AuthServiceTest`、`CompanyControllerMvcTest`、`CompanyOnboardingControllerTest`、`GlobalExceptionHandlerStatusContractTest`、`ErrorHttpStatusRegistryTest`、`GlobalExceptionHandlerMappingParityTest`、`R01BIndependentHttpMappingParityTest`、`CompanyResumeDownloadIntegrationTest`。
修改仅涉及新账号默认身份、新依赖装配、新枚举映射计数和新增接口计数;原角色、归属、隐私及历史映射断言保留。
新增设计与本报告;修改 `openapi-rc.json` 与 `verify_openapi.py`。没有修改迁移、依赖、短信服务商、Jenkins 或线上配置。
## 测试及真实基础设施
最终定向命令 `mvn -B -Dtest=UnifiedRegistrationIntegrationTest,IdentityPolicyTest,IdentitySelectionMySqlTest test`:55 / F=0 / E=0 / S=0。
其中策略测试 27、HTTP 集成 13、真实 MySQL 15;HTTP 库存测试独立列出公开与账号操作清单,不调用生产白名单生成期望,196 个受限 operation 全部拒绝。
首次全量 test 曾有 7 个失败(旧接口计数 + 旧接线精确次数),修正测试计数与过滤器主体边界后 `mvn test` 为 2952 / F=0 / E=0 / S=0。
之后新增失效会话 HTTP 反证并修复 500,最终 `mvn -B clean verify`:2953 / F=0 / E=0 / S=0 / BUILD SUCCESS,耗时 13:13。
独立读取 284 份 Surefire XML 汇总同为 2953 / 0 / 0 / 0。相对采用的 2889 基线,新增 55 个 JUnit 执行项,既有枚举参数化测试增加 9 项,数量自洽。
真实 MySQL 15 项及既有 Testcontainers 测试均实际执行;没有 Docker 不可用静默跳过。
MySQL/Testcontainers 不设置静默跳过 Docker 的开关。
并发使用 CountDownLatch,不用 sleep 调节竞争。
最新 JAR 验收使用隔离 MySQL 8.4、Redis 7.4、RabbitMQ 3;所有夹具仅在隔离库,不使用真实手机号或线上密钥。
短信 sender 使用受控 Mock,数据库获取测试验证码;不是实际运营商投递,也没有为前端增加 mockCode 旁路。
最新 JAR 三中间件黑盒场景包括:企业开关关闭安全拒绝;UNASSIGNED 空业务 roles;13 张表逐字段快照零写;求职者/企业身份选择;密码登录身份标记;全部旧 Access/Refresh 失效;同身份重试只读;跨身份冲突;未认证企业候选人访问被拒;真实 HTTP 双请求只一个 200、另一个 401;四个 RabbitMQ 队列消息数、ready、unacked 均为 0;精确删除三账号夹具及验证码,审计 append-only。
首次以 Maven exec:java 同 JVM 运行时,场景已产生 66 checks / 0 failures,但 Testcontainers Ryuk 守护线程在插件清理阶段被中断,导致外层 Maven 返回非零;保留该失败事实,不写成 BUILD SUCCESS。
最终改为独立 `java -cp` JVM 执行同一验收 main:再次 66 checks / 0 failures,进程正常退出 0,三容器按生命周期关闭,无插件线程清理异常。
最终结果在被忽略的 `target/identity-probe-result.json`,日志在 `docs/acceptance/unified-identity/jar-probe-standalone.log`;不新增未跟踪 JSON 验收产物。
被验收的最终 JAR SHA-256:`77d0125ba1d20007966be9fa7a7b3f82ef09fb5628b83bb5190262d318720cc9`。最终全量打包后仅重编译手工验收 main 的输出路径调整,没有再修改生产代码。
## OpenAPI 与前端切换
新增一个 path/operation,静态验证为 183 paths / 221 operations,`verify_openapi.py` All checks passed。
最新 JAR `/v3/api-docs` 的完整 path/method operation 集合与静态文件完全一致,不只比较数量。
前端登录成功后查看 identitySelectionRequired:true 进入身份选择页;false 根据旧 role 进入原工作台。
首次选择成功必须同时替换 Access/Refresh Token,不能继续使用旧 Token。
COMPANY 仍进入原 UNVERIFIED 入驻流程;不能直接进入已认证招聘能力。刷新后如需身份展示,继续查询 `/auth/me`。
新账号已不自动成为求职者,因此部署必须与前端身份选择页协调,不能宣称旧前端零改动兼容。
## 敏感信息分类
本轮新增源码、测试与两份文档扫描:完整 JWT、字面 BCrypt 摘要、邮箱、PRIVATE KEY、AWS key、本机用户绝对路径均为 0 命中。
手机号命中 22 行、15 个唯一值,全部来自独占的 `199000094xx/199000095xx/199000096xx` 假号段夹具;没有使用用户提供的真实手机号发送短信。
手工验收 main 中的测试密码、JWT 测试密钥和 Testcontainers 口令为显式 test-only 常量,只在隔离库/容器使用,不是生产凭据。
成功 JAR 日志通过自动断言确认不含三条测试手机号、测试明文密码、对应 BCrypt 摘要及指定签发 Token;拒绝响应不含异常类、SQL 连接串或存储字段。
失败的 MockMvc 证据可能包含已失效的本地测试 Token,日志由既有 `**/*.log` 规则忽略,不进入提交;不把这类失败证据宣称为“所有日志零 Token”。
历史启动日志中的本机目录与 Spring generated security password 警告仍属于既有环境输出,本轮未顺手重构该功能,也不声称这些观察项已消失。
## 边界与结束检查
最终分支 master,HEAD 仍为 `8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`,暂存区空,`git diff --check` 为 0。
V1–V46 与 pom.xml 无改动、无新迁移;WebMvcConfig 内容与 HEAD 一致,没有保留初版拦截器配置。
历史 290 个未跟踪条目未删除,当前 300 = 历史 290 + 本轮 10 个新增文件。原有未提交 Phase B/P0/岗位改动保留,不把它们记成本轮产出。
最新 JAR 两次启动按各自 Process 对象精确停止;最终无本轮 Java 残留,8080/8081 空闲。验收容器自动关闭,Docker 守护进程可保持运行。
三账号及验证码夹具精确清理,清理过程中不删除审计行;审计随隔离容器自然生命周期结束,不触及线上审计数据。四个消息队列在关闭前 messages/ready/unacked 均为 0。
未进入 Phase C;本轮不暂存、不提交、不推送、不部署。上线开关和已有生产账号由后续独立验收与发布检查点另行处理。

104
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths']))
assert len(d['paths']) == 182, f"Expected 182 paths, got {len(d['paths'])}"
assert len(d['paths']) == 198, f"Expected 198 paths (185 baseline + 13 module paths), got {len(d['paths'])}"
paths = d['paths']
@ -62,7 +62,9 @@ assert used_tags <= declared_tags, f'Undeclared operation tags: {sorted(used_tag
required = [
# Phase A 企业注册与入驻聚合(2 个 path / 2 个 operation)
'/auth/company/register',
'/auth/identity/select',
'/companies/me/onboarding',
'/companies/me/jobs',
'/review-weight-templates',
'/profile/me/review-weights',
'/profile/me/privacy',
@ -647,7 +649,7 @@ assert _auth_paths == {
'/auth/sms/send', '/auth/login', '/auth/me',
'/auth/refresh', '/auth/logout',
'/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset',
'/auth/company/register',
'/auth/company/register', '/auth/identity/select', '/auth/password/setup',
}, f'Unexpected /auth paths: {sorted(_auth_paths)}'
print(' S02 refresh/logout and password authentication operations validated')
@ -674,6 +676,18 @@ for _forbidden in ('materialsJson', 'decisionReason', 'reviewerId', 'storagePath
assert _forbidden not in _onboarding_text
print(' Phase A company registration/onboarding paths and redacted schemas validated')
# 企业岗位管理:私有全状态查询与公开岗位列表保持不同的安全边界。
assert set(paths['/companies/me/jobs']) == {'get'}
_company_jobs = paths['/companies/me/jobs']['get']
assert _company_jobs['operationId'] == 'getMyJobs'
assert {p['name'] for p in _company_jobs['parameters']} == {'status', 'keyword', 'city', 'page', 'size'}
assert _company_jobs['responses']['200']['content']['*/*']['schema']['$ref'] \
== '#/components/schemas/ApiResponsePageResponseCompanyManagedJobItem'
assert 'bearerAuth' in json.dumps(_company_jobs.get('security', []))
_company_job_properties = resolve_schema(d['components']['schemas']['CompanyManagedJobItem'])['properties']
assert {'id', 'companyId', 'title', 'city', 'salaryMin', 'salaryMax', 'status'} <= set(_company_job_properties)
print(' 企业自身全状态岗位列表路径、认证要求和强类型响应已验证')
# ── S03:唯一新增的企业简历下载接口 ────────────────────────────────
_download_path = '/company/candidates/{applicationId}/resume'
assert _download_path in paths, 'Missing company resume download endpoint'
@ -702,14 +716,96 @@ for _p in paths:
assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}'
print(' S03 company resume download contract validated; candidate DTOs remain redacted')
# 统计 operation 总数并校验 V43 + N07 + S02 + S03 + Phase A 计数 220
# 统计 operation 总数并校验当前基线 223 个既有 operation 加 18 个模块 operation。
operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations)
assert operations == 220, f"Expected 220 operations, got {operations}"
assert operations == 241, f"Expected 241 operations (223 baseline + 18 module operations), got {operations}"
assert paths['/auth/password/setup']['post']['operationId'] == 'setupPassword'
assert 'bearerAuth' in json.dumps(paths['/auth/password/setup']['post']['security'])
# 统一账号身份选择:只允许两种业务身份,保持旧登录字段引用。
_select = paths['/auth/identity/select']['post']
assert _select['requestBody']['content']['application/json']['schema']['$ref'] == '#/components/schemas/IdentitySelectionRequest'
assert set(d['components']['schemas']['IdentitySelectionRequest']['properties']['identity']['enum']) == {'STUDENT', 'COMPANY'}
assert 'identitySelectionRequired' in d['components']['schemas']['IdentitySelectionResponse']['properties']
assert 'reauthenticationRequired' in d['components']['schemas']['IdentitySelectionResponse']['properties']
print(" Offer advance endpoints all present")
print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)")
schemas = {name.removeprefix('Lxq'): schema for name, schema in
d['components']['schemas'].items() if name.startswith('Lxq')}
onboarding_success_refs = {
('/onboarding/companies/{companyId}/checklist', 'get'): 'ApiResponseEffectiveChecklistResponse',
('/onboarding/companies/{companyId}/checklist/items/{itemId}/completion', 'put'): 'ApiResponseProgressUpdateResponse',
('/onboarding/companies/{companyId}/knowledge', 'get'): 'ApiResponseCompanyKnowledgeOverviewResponse',
('/onboarding/company/checklist-items', 'get'): 'ApiResponseListCompanyChecklistItemView',
('/onboarding/company/checklist-items', 'post'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/checklist-items/{itemId}', 'put'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/checklist-items/{itemId}', 'delete'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/knowledge', 'get'): 'ApiResponseCompanyKnowledgeView',
('/onboarding/company/knowledge', 'put'): 'ApiResponseCompanyKnowledgeView',
}
for (path, method), schema_name in onboarding_success_refs.items():
response = paths[path][method]['responses']['200']
ref = response['content']['*/*']['schema']['$ref']
assert ref == f'#/components/schemas/Lxq{schema_name}', f'Wrong success schema for {method.upper()} {path}: {ref}'
for status_code in ('401', '403'):
assert status_code in paths[path][method]['responses'], f'Missing {status_code} for {method.upper()} {path}'
unauthorized = paths[path][method]['responses']['401']
assert unauthorized['content']['application/json']['schema']['$ref'] == '#/components/schemas/LxqUnauthorizedResponse'
forbidden = paths[path][method]['responses']['403']
assert forbidden['content']['application/json']['schema']['$ref'] == '#/components/schemas/LxqApiResponse'
assert forbidden['content']['application/json']['example']['code'] == 2002
onboarding_schema_fields = {
'CompanyChecklistItemCreateRequest': {'title', 'description', 'category', 'sortOrder', 'required', 'idempotencyKey'},
'CompanyChecklistItemUpdateRequest': {'title', 'description', 'category', 'sortOrder', 'required', 'active', 'version'},
'CompanyChecklistItemView': {'itemId', 'source', 'itemKey', 'title', 'description', 'category', 'required', 'sortOrder', 'active', 'version'},
'EffectiveChecklistResponse': {'companyId', 'companyName', 'customized', 'totalItems', 'completedItems', 'completionRate', 'items'},
'ChecklistItemView': {'itemId', 'source', 'itemKey', 'title', 'description', 'category', 'required', 'sortOrder', 'active', 'completed', 'completedAt', 'progressVersion'},
'ProgressUpdateRequest': {'completed', 'version'},
'ProgressUpdateResponse': {'itemId', 'completed', 'completedAt', 'progressVersion', 'totalItems', 'completedItems', 'completionRate'},
'CompanyKnowledgeUpdateRequest': {'culture', 'workPolicies', 'onboardingGuide', 'version'},
'CompanyKnowledgeView': {'configured', 'culture', 'workPolicies', 'onboardingGuide', 'updatedAt', 'version'},
'CompanyKnowledgeOverviewResponse': {'company', 'knowledgeState', 'knowledge', 'reviewSummary'},
'CompanyView': {'id', 'name', 'logoUrl', 'description', 'industry', 'website', 'address', 'verified'},
'KnowledgeView': {'culture', 'workPolicies', 'onboardingGuide', 'updatedAt', 'version'},
'ReviewSummaryView': {'state', 'totalReviews', 'averageRating', 'ratingDistribution', 'topTags', 'dimensionSummary'},
'DimensionSummaryView': {'averageScore', 'sampleCount', 'dataState'},
}
for schema_name, expected_fields in onboarding_schema_fields.items():
schema = schemas.get(schema_name)
assert schema is not None, f'Missing onboarding schema: {schema_name}'
assert expected_fields <= set(schema.get('properties', {})), (
f'Onboarding schema fields missing: {schema_name}'
)
create_request = schemas['CompanyChecklistItemCreateRequest']['properties']
assert create_request['sortOrder'].get('default') == 1000, (
'CompanyChecklistItemCreateRequest.sortOrder default must be 1000'
)
assert create_request['required'].get('default') is False, (
'CompanyChecklistItemCreateRequest.required default must be false'
)
knowledge_success_example = paths['/onboarding/company/knowledge']['put']['responses']['200']['content']['*/*']['example']
assert knowledge_success_example['data'].get('version') == 2, (
'PUT /onboarding/company/knowledge 200 example data.version must be 2'
)
onboarding_error_paths = {
10501: ('/onboarding/company/checklist-items/{itemId}', 'put', '404'),
10502: ('/onboarding/company/checklist-items/{itemId}', 'put', '409'),
10503: ('/onboarding/company/checklist-items', 'post', '409'),
10504: ('/onboarding/company/checklist-items', 'post', '409'),
10505: ('/onboarding/companies/{companyId}/checklist/items/{itemId}/completion', 'put', '409'),
10506: ('/onboarding/company/knowledge', 'put', '409'),
}
for error_code, (path, method, status_code) in onboarding_error_paths.items():
response_text = json.dumps(paths[path][method]['responses'][status_code], ensure_ascii=False)
assert str(error_code) in response_text, f'Missing onboarding error code {error_code} in {method.upper()} {path}'
print(' N04 onboarding endpoints and schemas present (9 operations / 6 paths)')
data = json.dumps(d)
assert '"" : "#/components/schemas/' not in data, "Found empty ref key"
print("No empty ref keys found")

6
mydreamjob-server/pom.xml

@ -25,6 +25,12 @@
</properties>
<dependencies>
<!-- 阿里云国内短信官方 SDK:凭据仅从服务器环境注入 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>4.6.0</version>
</dependency>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>

17
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java

@ -14,6 +14,9 @@ import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.notification.NotificationService;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import com.mydreamjob.profile.StudentProfile;
@ -44,6 +47,7 @@ public class ApplicationService {
private final ApplicationRepository applicationRepository;
private final JobService jobService;
private final UserRepository userRepository;
private final CompanyRepository companyRepository;
private final AuditService auditService;
private final ApplicationStatusHistoryRepository historyRepository;
private final NotificationService notificationService;
@ -132,12 +136,18 @@ public class ApplicationService {
/** 状态时间线 */
public List<Map<String, Object>> getTimeline(Long applicationId, Long userId) {
User actor = userRepository.findByIdWithRoles(userId).orElse(null);
Company actorCompany = null;
if (actor != null && actor.getRoles() != null && actor.getRoles().contains(User.Role.COMPANY)) {
actorCompany = VerifiedCompanyGuard.requireVerified(
companyRepository.findByOwnerUserId(userId).orElse(null));
}
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
// 仅学生本人或岗位所属企业可查看
Job job = jobService.getJob(app.getJobId());
boolean isStudent = app.getStudentUserId().equals(userId);
boolean isCompany = job.getCompanyId().equals(userId);
boolean isCompany = actorCompany != null && job.getCompanyId().equals(actorCompany.getId());
if (!isStudent && !isCompany) throw new BusinessException(ErrorCode.FORBIDDEN);
return historyRepository.findByApplicationIdOrderByCreatedAtAsc(applicationId)
@ -172,6 +182,7 @@ public class ApplicationService {
// ── 企业端 ────────────────────────────────────────────────
public List<Application> listCompanyApplications(Long companyId, Long jobId) {
jobService.requireVerifiedCompany(companyId);
Job job = jobService.getJob(jobId);
if (!job.getCompanyId().equals(companyId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
@ -180,6 +191,7 @@ public class ApplicationService {
public Page<Application> listCompanyApplications(Long companyId, Long jobId, String status, int page, int size) {
if (page < 0 || size < 1 || size > 100) throw new BusinessException(ErrorCode.BAD_REQUEST);
jobService.requireVerifiedCompany(companyId);
Job job = jobService.getJob(jobId);
if (!job.getCompanyId().equals(companyId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
@ -197,6 +209,7 @@ public class ApplicationService {
@Transactional
public Application updateStatus(Long companyId, Long applicationId, ApplicationStatusRequest req,
com.mydreamjob.audit.AuditContext ctx) {
jobService.requireVerifiedCompany(companyId);
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId());
@ -265,6 +278,7 @@ public class ApplicationService {
public CandidateListResponse listCompanyCandidates(Long companyId, Long jobId, String status,
String keyword, String sort, String order,
int page, int size) {
jobService.requireVerifiedCompany(companyId);
// 参数校验
if (page < 0 || size < 1 || size > 100)
throw new BusinessException(ErrorCode.BAD_REQUEST);
@ -349,6 +363,7 @@ public class ApplicationService {
*/
@Transactional(readOnly = true)
public CandidateDetailView getCompanyCandidateDetail(Long companyId, Long applicationId) {
jobService.requireVerifiedCompany(companyId);
// 单次 JOIN 查询:applications + jobs + student_profiles + users
// 查询结果包含岗位 companyId,数据隔离在 Service 层比较,避免额外的 findById 二次查询
TypedQuery<Object[]> query = em.createQuery(

16
mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java

@ -4,6 +4,7 @@ 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.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
import com.mydreamjob.privacy.ResumeVisibility;
@ -69,10 +70,11 @@ public class CompanyResumeAccessPolicy {
if (company == null) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
VerifiedCompanyGuard.requireVerified(company);
Application application = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
if (!isVisibleToCompanyInternal(application, company.getId())) {
if (!isVisibleToCompanyInternal(application, company)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return new Decision(application.getStudentUserId());
@ -88,17 +90,22 @@ public class CompanyResumeAccessPolicy {
if (studentUserId == null || companyId == null || applicationId == null) {
return false;
}
Company company = companyRepository.findById(companyId).orElse(null);
if (!VerifiedCompanyGuard.isVerified(company)) {
return false;
}
Application application = applicationRepository.findById(applicationId).orElse(null);
if (application == null || !studentUserId.equals(application.getStudentUserId())) {
return false;
}
return isVisibleToCompanyInternal(application, companyId);
return isVisibleToCompanyInternal(application, company);
}
/**
* 核心判定:投递所属岗位必须属于该企业,再按学生隐私设置决定可见性。
*/
private boolean isVisibleToCompanyInternal(Application application, Long companyId) {
private boolean isVisibleToCompanyInternal(Application application, Company company) {
Long companyId = company.getId();
Job job = jobRepository.findById(application.getJobId()).orElse(null);
if (job == null || !companyId.equals(job.getCompanyId())) {
// 岗位不存在或不属于当前企业:一律拒绝(含跨企业投递)。
@ -113,8 +120,7 @@ public class CompanyResumeAccessPolicy {
return application.getStatus() != null
&& APPLIED_COMPANY_VISIBLE_STATUSES.contains(application.getStatus());
case VERIFIED_COMPANIES:
Company company = companyRepository.findById(companyId).orElse(null);
return company != null && Boolean.TRUE.equals(company.getVerified());
return VerifiedCompanyGuard.isVerified(company);
case PRIVATE:
default:
// 无设置记录、null 或 PRIVATE:按 PRIVATE 处理。

15
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java

@ -38,6 +38,7 @@ public class AuthService {
private final AccountDeletionPhoneHasher accountDeletionPhoneHasher;
private final AuthSessionService authSessionService;
private final Clock clock;
private final com.mydreamjob.auth.password.PasswordSetupService passwordSetupService;
/**
* Mock 登录:验证短信验证码后自动创建/登录用户,并建立一次登录会话。
@ -62,7 +63,8 @@ public class AuthService {
user = User.builder()
.phone(phone)
.nickname("用户_" + phone.substring(7))
.role(User.Role.STUDENT)
.role(User.Role.UNASSIGNED)
.roles(Set.of())
.active(true)
.verified(true)
.build();
@ -97,6 +99,15 @@ public class AuthService {
result.put("phone", user.getPhone());
result.put("nickname", user.getNickname());
result.put("role", user.getRole());
result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED);
result.put("passwordConfigured", user.getPasswordHash() != null);
if ("login".equals(purpose) && user.getPasswordHash() == null) {
String setupProof = passwordSetupService.issue(user, tokens.sessionId(), capturedNow);
if (setupProof != null) {
result.put("passwordSetupToken", setupProof);
result.put("passwordSetupExpiresIn", 600);
}
}
result.put("accessToken", tokens.accessToken());
result.put("refreshToken", tokens.refreshToken());
result.put("expiresIn", EXPIRES_IN_SECONDS);
@ -137,7 +148,9 @@ public class AuthService {
result.put("nickname", user.getNickname());
result.put("avatarUrl", user.getAvatarUrl());
result.put("role", user.getRole());
result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED);
result.put("active", user.getActive());
result.put("passwordConfigured", user.getPasswordHash() != null);
result.put("verified", user.getVerified());
result.put("createdAt", user.getCreatedAt());
return result;

2
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java

@ -336,7 +336,7 @@ public class AuthSessionService {
return authTokenHasher.hash("sid:" + sessionId).substring(0, 16);
}
static LocalDateTime toLocal(Instant instant) {
public static LocalDateTime toLocal(Instant instant) {
return LocalDateTime.ofInstant(instant, BUSINESS_ZONE);
}
}

5
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java

@ -53,6 +53,11 @@ public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
List<AuthToken> findByUserIdAndSessionIdOrderByIdAsc(Long userId, String sessionId);
/** 首次密码设置持有当前会话行锁,让并发登出与设置按事务顺序生效。 */
@org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT t FROM AuthToken t WHERE t.userId = :userId AND t.sessionId = :sessionId ORDER BY t.id")
List<AuthToken> lockSession(@Param("userId") Long userId, @Param("sessionId") String sessionId);
// ── S02 原子条件更新(返回值必须被严格检查)────────────────────
/**

23
mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsFailedAttemptRecorder.java

@ -0,0 +1,23 @@
package com.mydreamjob.auth;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* Persist an invalid-code attempt even when the surrounding login or registration rolls back.
* A conditional database update prevents concurrent guesses from losing increments.
*/
@Service
@RequiredArgsConstructor
public class SmsFailedAttemptRecorder {
private final SmsVerificationCodeRepository codes;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(Long codeId, LocalDateTime now) {
codes.incrementFailedAttemptsIfValid(codeId, now);
}
}

6
mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java

@ -19,6 +19,12 @@ public interface SmsVerificationCodeRepository extends JpaRepository<SmsVerifica
"AND c.attempts < c.maxAttempts")
int consumeIfValid(@Param("id") Long id, @Param("now") LocalDateTime now);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE SmsVerificationCode c SET c.attempts = c.attempts + 1 " +
"WHERE c.id = :id AND c.used = false AND c.expiredAt > :now " +
"AND c.attempts < c.maxAttempts")
int incrementFailedAttemptsIfValid(@Param("id") Long id, @Param("now") LocalDateTime now);
List<SmsVerificationCode> findByPhoneAndCreatedAtAfter(String phone, LocalDateTime after);
/**

12
mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java

@ -34,6 +34,7 @@ public class SmsVerificationService {
private final RedisTemplate<String, Object> redisTemplate;
private final AuditService auditService;
private final SmsCodeSender smsCodeSender;
private final SmsFailedAttemptRecorder failedAttemptRecorder;
@Value("${app.sms.mock.code-length:6}")
private int codeLength;
@ -73,9 +74,10 @@ public class SmsVerificationService {
throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
}
// 检查频率限制(60秒内最多1次)
// Redis SET NX EX 原子占位,防止并发请求同时越过发送频率限制。
String rateLimitKey = RATE_LIMIT_KEY_PREFIX + phone;
if (redisTemplate.hasKey(rateLimitKey)) {
if (!Boolean.TRUE.equals(redisTemplate.opsForValue()
.setIfAbsent(rateLimitKey, "1", 60, TimeUnit.SECONDS))) {
log.warn("SMS send rejected: rate limited, phone={}", maskPhone(phone));
throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
}
@ -96,9 +98,6 @@ public class SmsVerificationService {
.build();
codeRepository.save(smsCode);
// 设置频率限制
redisTemplate.opsForValue().set(rateLimitKey, "1", 60, TimeUnit.SECONDS);
// 投递验证码:实现方保证不把验证码/完整手机号写入日志。
// 不可用实现会在此抛出 SERVICE_UNAVAILABLE,令本次事务回滚(不产生虚假的“发送成功”)。
smsCodeSender.sendVerificationCode(phone, purpose, code);
@ -158,8 +157,7 @@ public class SmsVerificationService {
// 校验验证码值
if (!code.equals(smsCode.getCode())) {
smsCode.setAttempts(smsCode.getAttempts() + 1);
codeRepository.save(smsCode);
failedAttemptRecorder.record(smsCode.getId(), now);
throw new BusinessException(ErrorCode.SMS_CODE_INVALID);
}

1
mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java

@ -75,6 +75,7 @@ public class User {
private LocalDateTime updatedAt;
public enum Role {
UNASSIGNED,
STUDENT,
COMPANY,
ADMIN

6
mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java

@ -2,6 +2,7 @@ package com.mydreamjob.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@ -14,6 +15,11 @@ import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByPhone(String phone);
/** Load the role collection for service-layer authorization checks after repository return. */
@EntityGraph(attributePaths = "roles")
@Query("select u from User u where u.id = :id")
Optional<User> findByIdWithRoles(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select u from User u where u.phone = :phone")
Optional<User> findByPhoneForUpdate(@Param("phone") String phone);

4
mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java

@ -27,6 +27,10 @@ public class CompanyRegistrationRequest {
@Data
public static class CompanyDetails {
@com.fasterxml.jackson.annotation.JsonAnySetter
public void rejectUnknown(String name, Object value) {
throw new IllegalArgumentException("company details contain unsupported fields");
}
@NotBlank
@Size(max = 100)
private String name;

11
mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupRequest.java

@ -0,0 +1,11 @@
package com.mydreamjob.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record PasswordSetupRequest(@NotBlank @Size(max = 512)
@io.swagger.v3.oas.annotations.media.Schema(accessMode = io.swagger.v3.oas.annotations.media.Schema.AccessMode.WRITE_ONLY) String passwordSetupToken,
@NotBlank @Size(max = 256)
@io.swagger.v3.oas.annotations.media.Schema(accessMode = io.swagger.v3.oas.annotations.media.Schema.AccessMode.WRITE_ONLY) String newPassword) {
@Override public String toString() { return "PasswordSetupRequest[redacted]"; }
}

6
mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupResponse.java

@ -0,0 +1,6 @@
package com.mydreamjob.auth.dto;
public record PasswordSetupResponse(boolean passwordConfigured, boolean reauthenticationRequired,
boolean identitySelectionRequired, String accessToken, String refreshToken, long expiresIn) {
@Override public String toString() { return "PasswordSetupResponse[redacted]"; }
}

76
mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java

@ -0,0 +1,76 @@
package com.mydreamjob.auth.identity;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.security.CurrentUserAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import java.io.IOException;
import java.util.Set;
/** 已认证但未选择身份的账号:默认拒绝业务,只允许精确的方法/路径白名单。 */
@Component
@RequiredArgsConstructor
public class IdentityAccessFilter extends OncePerRequestFilter {
private static final Set<String> AUTH_POST = Set.of("/auth/identity/select", "/auth/logout",
"/auth/password/set", "/auth/password/setup", "/auth/password/change", "/auth/login", "/auth/password/login",
"/auth/password/reset", "/auth/refresh", "/auth/sms/send", "/auth/company/register");
private static final Set<String> PUBLIC_GET = Set.of("/", "/health", "/health/**", "/jobs",
"/jobs/search", "/jobs/filters", "/jobs/compare", "/jobs/public/**", "/jobs/*/reviews",
"/jobs/*/reviews/*", "/jobs/*/reviews/summary", "/companies/*", "/companies/*/jobs",
"/job-recommendation-taxonomies", "/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**");
private final UserRepository users;
private final CurrentUserAccessor currentUser;
private final ObjectMapper mapper;
private final RequestIdAccessor requestIds;
private final AntPathMatcher paths = new AntPathMatcher();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 当前生产认证仅由 JwtAuthenticationFilter 建立;只使用其已校验的会话主体。
// 匿名请求继续交给既有 Security 入口,测试注入的非 JWT 主体不新增解析行为。
var authentication=org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
if(authentication==null || !(authentication.getDetails() instanceof com.mydreamjob.auth.SessionAuthenticationDetails)) {
chain.doFilter(request,response); return;
}
Long id;
try { id = currentUser.requireUserId(); }
catch (InsufficientAuthenticationException e) { chain.doFilter(request,response); return; }
User user;
try { user = users.findById(id).orElse(null); }
catch (org.springframework.dao.DataAccessException e) {
reject(request,response,503,ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE); return;
}
if (user == null || user.getRole() != User.Role.UNASSIGNED) {chain.doFilter(request,response); return;}
String uri = request.getRequestURI().substring(request.getContextPath().length());
if (allowed(request.getMethod(), uri)) {chain.doFilter(request,response); return;}
reject(request,response,403,ErrorCode.IDENTITY_SELECTION_REQUIRED);
}
private void reject(HttpServletRequest request, HttpServletResponse response, int status, ErrorCode error) throws IOException {
response.setStatus(status);
response.setContentType("application/json;charset=UTF-8");
mapper.writeValue(response.getWriter(), ApiResponse.error(error.getCode(),error.getMessage(), requestIds.get(request)));
}
boolean allowed(String method, String uri) {
if ("OPTIONS".equals(method) || paths.match("/actuator/**", uri)) return true;
if ("POST".equals(method)) return AUTH_POST.contains(uri);
if (!"GET".equals(method) && !"HEAD".equals(method)) return false;
// /companies/* 是公开企业 ID 路径,不能误把保留字 me 当作公开主页。
if ("/companies/me".equals(uri) || uri.startsWith("/companies/me/")) return false;
return "/auth/me".equals(uri) || PUBLIC_GET.stream().anyMatch(pattern -> paths.match(pattern, uri));
}
}

31
mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionRequest.java

@ -0,0 +1,31 @@
package com.mydreamjob.auth.identity;
import com.mydreamjob.auth.company.CompanyRegistrationRequest.CompanyDetails;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
import io.swagger.v3.oas.annotations.media.Schema;
@Getter
@Setter
public class IdentitySelectionRequest {
public enum Identity { STUDENT, COMPANY }
@NotNull
private Identity identity;
@Valid
private CompanyDetails company;
@com.fasterxml.jackson.annotation.JsonAnySetter
public void rejectUnknown(String name, Object value) {
throw new IllegalArgumentException("request contains unsupported fields");
}
@AssertTrue(message = "company details are required only for company identity")
@Schema(hidden = true)
public boolean isCompanyShapeValid() {
return identity == null || (identity == Identity.COMPANY ? company != null : company == null);
}
}

8
mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionResponse.java

@ -0,0 +1,8 @@
package com.mydreamjob.auth.identity;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record IdentitySelectionResponse(Long userId, String role, boolean identitySelectionRequired,
Long companyId, String onboardingStatus, String accessToken, String refreshToken,
Long expiresIn, boolean reauthenticationRequired) { }

104
mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionService.java

@ -0,0 +1,104 @@
package com.mydreamjob.auth.identity;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.*;
import com.mydreamjob.auth.company.CompanyRegistrationProperties;
import com.mydreamjob.auth.company.CompanyRegistrationRequest.CompanyDetails;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.CompanyOnboardingService;
import com.mydreamjob.deletion.DeletionStatus;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.deletion.AccountDeletionRepository;
import jakarta.validation.Validator;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class IdentitySelectionService {
private final UserRepository users;
private final CompanyRepository companies;
private final AuthSessionService sessions;
private final AuditService audit;
private final AccountDeletionRepository deletions;
private final CompanyRegistrationProperties properties;
private final CompanyOnboardingService onboarding;
private final Validator validator;
private final Clock clock;
@Transactional
public IdentitySelectionResponse select(Long userId, String sessionId, IdentitySelectionRequest request,
AuditContext context) {
if (request == null || !validator.validate(request).isEmpty()) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
try {
// 与刷新/密码变更相同:users -> auth_tokens;锁内重新确认会话,关闭 TOCTOU。
User user = users.findByIdForUpdate(userId).orElseThrow(InvalidTokenException::new);
Instant now = clock.instant();
if (!Boolean.TRUE.equals(user.getActive()) || !sessions.isSessionActive(userId, sessionId, now)) {
throw new InvalidTokenException();
}
if (deletions.findByUserId(userId).filter(d -> d.getStatus() != DeletionStatus.NONE).isPresent()) {
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT);
}
User.Role target = request.getIdentity() == IdentitySelectionRequest.Identity.STUDENT
? User.Role.STUDENT : User.Role.COMPANY;
if (user.getRole() != User.Role.UNASSIGNED) {
if (user.getRole() != target || user.getRoles() == null || !user.getRoles().equals(Set.of(target))) {
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT);
}
Company company = target == User.Role.COMPANY ? companies.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT)) : null;
// 不重复更改资料/颁发会话/审计;客户端可使用已有凭证或重新登录。
return response(user, company, null, true);
}
if (user.getRoles() == null || !user.getRoles().isEmpty() || companies.existsByOwnerUserId(userId)) {
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT);
}
if (target == User.Role.COMPANY && !properties.isEnabled()) {
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_DISABLED);
}
user.setRole(target);
user.setRoles(new HashSet<>(Set.of(target)));
users.saveAndFlush(user);
Company company = null;
if (target == User.Role.COMPANY) {
CompanyDetails d = request.getCompany();
company = companies.saveAndFlush(Company.builder().ownerUserId(userId).name(d.getName())
.logoUrl(d.getLogoUrl()).industry(d.getIndustry()).description(d.getDescription())
.website(d.getWebsite()).address(d.getAddress()).verified(false).build());
}
// modifying 会清理 persistence context;上面的角色与资料已 flush,锁仍持有。
sessions.revokeAllUserSessions(userId, "IDENTITY_SELECTED", now);
AuthSessionService.SessionTokens tokens = sessions.createSession(user, now, context);
audit.logRequired(userId, "IDENTITY_SELECTED", "USER", userId,
"{\"result\":\"SUCCESS\"}", context, LocalDateTime.ofInstant(now, AuthSessionService.BUSINESS_ZONE));
return response(user, company, tokens, false);
} catch (DataIntegrityViolationException e) {
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT);
} catch (DataAccessException e) {
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE);
}
}
private IdentitySelectionResponse response(User user, Company company, AuthSessionService.SessionTokens tokens,
boolean reauthenticate) {
return new IdentitySelectionResponse(user.getId(), user.getRole().name(), false,
company == null ? null : company.getId(), company == null ? null :
(tokens == null ? onboarding.get(user.getId()).onboardingStatus() : "UNVERIFIED"),
tokens == null ? null : tokens.accessToken(), tokens == null ? null : tokens.refreshToken(),
tokens == null ? null : AuthService.EXPIRES_IN_SECONDS, reauthenticate);
}
}

5
mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java

@ -59,6 +59,8 @@ public class PasswordAuthenticationService {
result.put("phone", user.getPhone());
result.put("nickname", user.getNickname());
result.put("role", user.getRole());
result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED);
result.put("passwordConfigured", true);
result.put("accessToken", tokens.accessToken());
result.put("refreshToken", tokens.refreshToken());
result.put("expiresIn", AuthService.EXPIRES_IN_SECONDS);
@ -116,7 +118,8 @@ public class PasswordAuthenticationService {
return new PasswordOperationResult(true, true);
}
private User lockedUsableUser(Long userId) {
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public User lockedUsableUser(Long userId) {
User user = userRepository.findByIdForUpdate(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.CREDENTIALS_INVALID, "手机号或密码错误"));
if (!usable(user)) {

18
mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallenge.java

@ -0,0 +1,18 @@
package com.mydreamjob.auth.password;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import lombok.*;
@Entity
@Table(name = "password_setup_challenges")
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
public class PasswordSetupChallenge {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(nullable = false) private Long userId;
@Column(nullable = false, length = 36) private String sessionId;
@Column(nullable = false, unique = true, length = 64) private String tokenHash;
@Column(nullable = false) private LocalDateTime createdAt;
@Column(nullable = false) private LocalDateTime expiresAt;
private LocalDateTime consumedAt;
}

16
mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallengeRepository.java

@ -0,0 +1,16 @@
package com.mydreamjob.auth.password;
import java.time.LocalDateTime;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
public interface PasswordSetupChallengeRepository extends JpaRepository<PasswordSetupChallenge, Long> {
@Modifying(flushAutomatically = true)
@Query("""
update PasswordSetupChallenge c set c.consumedAt = :now
where c.userId = :user and c.sessionId = :session and c.tokenHash = :hash
and c.consumedAt is null and c.expiresAt > :now
""")
int consume(@Param("user") Long userId, @Param("session") String sessionId,
@Param("hash") String hash, @Param("now") LocalDateTime now);
}

73
mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupService.java

@ -0,0 +1,73 @@
package com.mydreamjob.auth.password;
import com.mydreamjob.auth.*;
import com.mydreamjob.auth.dto.PasswordSetupResponse;
import com.mydreamjob.audit.*;
import com.mydreamjob.common.exception.*;
import java.security.SecureRandom;
import java.time.*;
import java.util.Base64;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** 短信登录后的首次密码设置;凭证只绑定当前会话,不用会话创建时间推断认证来源。 */
@Service @RequiredArgsConstructor
public class PasswordSetupService {
private static final SecureRandom RANDOM = new SecureRandom();
private final PasswordSetupChallengeRepository challenges;
private final AuthTokenHasher hasher;
private final AuthTokenRepository tokens;
private final PasswordAuthenticationService passwords;
private final PasswordRateLimiter limiter;
private final PasswordPolicy policy;
private final PasswordEncoder encoder;
private final UserRepository users;
private final AuthSessionService sessions;
private final AuditService audit;
private final Clock clock;
private final PasswordAuthenticationProperties properties;
@Transactional
public String issue(User user, String sessionId, Instant now) {
if (!properties.isEnabled() || user.getPasswordHash() != null) return null;
byte[] random = new byte[32];
RANDOM.nextBytes(random);
String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
challenges.save(PasswordSetupChallenge.builder().userId(user.getId()).sessionId(sessionId)
.tokenHash(hash(raw)).createdAt(AuthSessionService.toLocal(now))
.expiresAt(AuthSessionService.toLocal(now.plusSeconds(600))).build());
return raw;
}
@Transactional
public PasswordSetupResponse setup(Long userId, String sessionId, String raw, String newPassword, AuditContext ctx) {
User user = passwords.lockedUsableUser(userId);
Instant now = clock.instant();
boolean active = tokens.lockSession(userId, sessionId).stream().anyMatch(t ->
Boolean.FALSE.equals(t.getRevoked()) && t.getConsumedAt() == null
&& t.getExpiredAt().isAfter(AuthSessionService.toLocal(now)));
if (!active)
throw new InvalidTokenException();
limiter.checkOperationAllowed(user.getPhone());
if (user.getPasswordHash() != null) throw new BusinessException(ErrorCode.PASSWORD_STATE_CONFLICT);
policy.validateNewPassword(newPassword, user.getPhone(), null, encoder);
if (raw == null || raw.length() != 43 || challenges.consume(userId, sessionId, hash(raw),
AuthSessionService.toLocal(now)) != 1)
throw new BusinessException(ErrorCode.CREDENTIALS_INVALID, "invalid password setup proof");
user.setPasswordHash(encoder.encode(policy.normalize(newPassword)));
user.setPasswordChangedAt(AuthSessionService.toLocal(now));
users.saveAndFlush(user);
sessions.revokeAllUserSessions(userId, "PASSWORD_SET", now);
var pair = sessions.createSession(user, now, ctx);
audit.log(userId, "PASSWORD_SET", "AUTH", null, "First password configured", ctx,
AuthSessionService.toLocal(now));
audit.log(userId, "AUTH_SESSIONS_REVOKED", "AUTH", null, "PASSWORD_SET", ctx,
AuthSessionService.toLocal(now));
return new PasswordSetupResponse(true, false, user.getRole() == User.Role.UNASSIGNED,
pair.accessToken(), pair.refreshToken(), AuthService.EXPIRES_IN_SECONDS);
}
private String hash(String raw) { return hasher.hash("password-setup:" + raw); }
}

57
mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/AliyunSmsCodeSender.java

@ -0,0 +1,57 @@
package com.mydreamjob.auth.sms;
import com.aliyun.dysmsapi20170525.Client;
import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
import com.aliyun.teautil.models.RuntimeOptions;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Set;
/** SDK 返回 OK 只代表网关受理,不代表手机已收到;禁止自动重试非幂等发送。 */
public final class AliyunSmsCodeSender implements SmsCodeSender {
private static final Logger LOG = LoggerFactory.getLogger(AliyunSmsCodeSender.class);
private static final Set<String> PURPOSES = Set.of("login", "password-set", "password-reset", "company-register");
private final Client client;
private final String signName;
private final String templateCode;
private final int connectTimeoutMs;
private final int readTimeoutMs;
public AliyunSmsCodeSender(Client client, AliyunSmsProperties properties) {
properties.validateForSending();
this.client = client;
this.signName = properties.getSignName();
this.templateCode = properties.getTemplateCode();
this.connectTimeoutMs = properties.getConnectTimeoutMs();
this.readTimeoutMs = properties.getReadTimeoutMs();
}
@Override
public void sendVerificationCode(String phone, String purpose, String code) {
if (phone == null || !phone.matches("[0-9]{11}") || purpose == null || !PURPOSES.contains(purpose)
|| code == null || !code.matches("[0-9]{6}")) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid SMS request");
}
SendSmsRequest request = new SendSmsRequest().setPhoneNumbers(phone).setSignName(signName)
.setTemplateCode(templateCode).setTemplateParam("{\"code\":\"" + code + "\"}");
RuntimeOptions runtime = new RuntimeOptions().setAutoretry(false)
.setConnectTimeout(connectTimeoutMs).setReadTimeout(readTimeoutMs);
SendSmsResponse response;
try {
response = client.sendSmsWithOptions(request, runtime);
} catch (Exception ignored) {
if (ignored instanceof InterruptedException) Thread.currentThread().interrupt();
// SDK 异常可能包含请求绑定值,既不透传原因也不输出堆栈。
LOG.warn("Aliyun SMS gateway call failed");
throw new BusinessException(ErrorCode.SMS_SEND_FAILED);
}
if (response == null || response.getBody() == null || response.getStatusCode() == null
|| response.getStatusCode() != 200 || !"OK".equals(response.getBody().getCode())) {
LOG.warn("Aliyun SMS gateway did not accept request");
throw new BusinessException(ErrorCode.SMS_SEND_FAILED);
}
}
}

35
mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/AliyunSmsProperties.java

@ -0,0 +1,35 @@
package com.mydreamjob.auth.sms;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** 不生成 toString,避免配置对象输出凭据。只有显式 true 才启用。 */
@Getter
@Setter
@ConfigurationProperties(prefix = "app.sms.aliyun")
public class AliyunSmsProperties {
private String enabled = "false";
private String accessKeyId;
private String accessKeySecret;
private String signName;
private String templateCode;
private int connectTimeoutMs = 3000;
private int readTimeoutMs = 5000;
public boolean explicitlyEnabled() {
return enabled != null && "true".equalsIgnoreCase(enabled.trim());
}
public void validateForSending() {
if (blank(accessKeyId) || blank(accessKeySecret) || blank(signName) || blank(templateCode)
|| !templateCode.matches("SMS_[A-Za-z0-9_]+")
|| connectTimeoutMs < 1000 || connectTimeoutMs > 30000
|| readTimeoutMs < 1000 || readTimeoutMs > 30000) {
// 不使用包含 rejected value 的校验异常,避免绑定密钥进入启动日志。
throw new IllegalStateException("SMS configuration incomplete or invalid");
}
}
private static boolean blank(String value) { return value == null || value.isBlank(); }
}

3
mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java

@ -3,7 +3,8 @@ package com.mydreamjob.auth.sms;
/**
* 短信验证码发送抽象。
*
* <p>由 {@code app.sms.mock.enabled} 决定加载哪一个实现:
* <p>真实发送由 {@code app.sms.aliyun.enabled=true} 和完整凭据配置启用;
* 未启用真实发送时,由 {@code app.sms.mock.enabled} 决定回退实现:
* <ul>
* <li>{@code true} → {@link MockSmsCodeSender}(本地/测试环境,不调用外部网关)</li>
* <li>{@code false} 或未配置 → {@link UnavailableSmsCodeSender}(安全失败,不伪造成功)</li>

26
mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java

@ -3,15 +3,17 @@ package com.mydreamjob.auth.sms;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
/**
* 短信发送实现装配(单 Bean 方案)。
* 短信发送实现装配(单 Bean 方案,真实发送与 Mock 互斥)。
*
* <p>由本配置类<strong>始终且仅</strong>产生一个 {@link SmsCodeSender},从而彻底避免
* “两个互斥 {@code @ConditionalOnProperty} 在空值/空白值下都不匹配 → 0 个 Bean →
* 模糊 Spring 装配错误导致启动失败”的问题。</p>
*
* <p>取值语义(大小写不敏感,两侧空白忽略):</p>
* <p>显式启用 Aliyun 时必须具备完整配置,禁止同时启用 Mock;否则启动安全失败。
* 未启用 Aliyun 时,Mock 取值语义(大小写不敏感,两侧空白忽略):</p>
* <ul>
* <li>{@code true} → {@link MockSmsCodeSender}</li>
* <li>{@code false} / 缺失 / 空字符串 / 仅空白 / 其他非法值 → {@link UnavailableSmsCodeSender}</li>
@ -21,6 +23,7 @@ import org.springframework.context.annotation.Configuration;
* 既不启用 Mock,也不进入发送流程。本类不输出配置原值到日志。</p>
*/
@Configuration
@EnableConfigurationProperties(AliyunSmsProperties.class)
public class SmsSenderConfiguration {
/** 显式启用 Mock 的唯一取值。 */
@ -28,8 +31,23 @@ public class SmsSenderConfiguration {
@Bean
public SmsCodeSender smsCodeSender(
@Value("${app.sms.mock.enabled:false}") String rawEnabled) {
if (rawEnabled != null && MOCK_ENABLED_VALUE.equalsIgnoreCase(rawEnabled.trim())) {
@Value("${app.sms.mock.enabled:false}") String rawEnabled,
AliyunSmsProperties properties) {
boolean mockEnabled = rawEnabled != null && MOCK_ENABLED_VALUE.equalsIgnoreCase(rawEnabled.trim());
if (properties.explicitlyEnabled()) {
if (mockEnabled) throw new IllegalStateException("SMS mock and real sender cannot both be enabled");
properties.validateForSending();
try {
var config = new com.aliyun.teaopenapi.models.Config()
.setAccessKeyId(properties.getAccessKeyId())
.setAccessKeySecret(properties.getAccessKeySecret())
.setEndpoint("dysmsapi.aliyuncs.com").setProtocol("HTTPS");
return new AliyunSmsCodeSender(new com.aliyun.dysmsapi20170525.Client(config), properties);
} catch (Exception ignored) {
throw new IllegalStateException("SMS client initialization failed");
}
}
if (mockEnabled) {
return new MockSmsCodeSender();
}
return new UnavailableSmsCodeSender();

2
mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java

@ -9,7 +9,7 @@ import com.mydreamjob.common.exception.ErrorCode;
* <p>由 {@link SmsSenderConfiguration} 在 {@code app.sms.mock.enabled} 为 false、缺失、
* 空值、仅空白或其他非法值时装配(不再使用条件注解,以保证任何取值下恰好一个 Bean)。</p>
*
* <p>当前未接入真实短信网关,因此本实现<strong>安全失败</strong>:
* <p>未显式启用真实短信网关或 Mock 时,本实现<strong>安全失败</strong>:
* 抛出既有的“服务不可用”业务异常,绝不返回虚假的“发送成功”。
* 异常消息为固定安全文本,不含手机号、验证码或任何配置细节。</p>
*/

18
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java

@ -10,6 +10,7 @@ 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.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import lombok.RequiredArgsConstructor;
@ -78,10 +79,11 @@ public class BidirectionalMatchService {
Long userId, Long applicationId, int score, AuditContext ctx) {
User user = loadUser(userId);
assertRole(user, User.Role.COMPANY);
Company company = VerifiedCompanyGuard.requireVerified(
companyRepository.findByOwnerUserId(userId).orElse(null));
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Long companyId = companyRepository.findByOwnerUserId(userId)
.map(Company::getId).orElse(null);
Long companyId = company.getId();
Job job = jobService.getJob(app.getJobId());
if (companyId == null || !companyId.equals(job.getCompanyId())) {
throw new BusinessException(ErrorCode.FORBIDDEN);
@ -97,6 +99,10 @@ public class BidirectionalMatchService {
// ── 匹配状态查询(双方可读) ──────────────────────────────
public Map<String, Object> getMatchView(Long userId, Long applicationId) {
User actor = userRepository.findByIdWithRoles(userId).orElse(null);
if (actor != null && actor.getRoles() != null && actor.getRoles().contains(User.Role.COMPANY)) {
VerifiedCompanyGuard.requireVerified(companyRepository.findByOwnerUserId(userId).orElse(null));
}
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId());
@ -139,9 +145,11 @@ public class BidirectionalMatchService {
private void assertParticipant(Long userId, Application app, Job job) {
if (app.getStudentUserId().equals(userId)) return;
Long companyId = companyRepository.findByOwnerUserId(userId)
.map(Company::getId).orElse(null);
if (companyId != null && companyId.equals(job.getCompanyId())) return;
Company company = companyRepository.findByOwnerUserId(userId).orElse(null);
if (company != null && company.getId().equals(job.getCompanyId())) {
VerifiedCompanyGuard.requireVerified(company);
return;
}
throw new BusinessException(ErrorCode.FORBIDDEN);
}

6
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java

@ -6,6 +6,7 @@ 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.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.messaging.Conversation;
@ -43,6 +44,8 @@ public class MatchConversationService {
@Transactional
public Map<String, Object> createOrGetMatchConversation(Long userId, Long applicationId) {
Company ownedCompany = companyRepository.findByOwnerUserId(userId).orElse(null);
if (ownedCompany != null) VerifiedCompanyGuard.requireVerified(ownedCompany);
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId());
@ -56,8 +59,7 @@ public class MatchConversationService {
.map(Company::getOwnerUserId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
} else {
Long actorCompanyId = companyRepository.findByOwnerUserId(userId)
.map(Company::getId).orElse(null);
Long actorCompanyId = ownedCompany == null ? null : ownedCompany.getId();
if (actorCompanyId == null || !actorCompanyId.equals(companyId)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}

3
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java

@ -6,6 +6,7 @@ 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.company.VerifiedCompanyGuard;
import com.mydreamjob.security.UserRoleGuard;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@ -33,6 +34,7 @@ public class MatchRuleService {
@Transactional(readOnly = true)
public Map<String, Object> getCompanyRule(Long userId) {
Company company = requireCompany(userId);
VerifiedCompanyGuard.requireVerified(company);
CompanyMatchRule rule = companyRuleRepository.findByCompanyId(company.getId()).orElse(null);
int required = rule != null ? rule.getRequiredCandidateScore()
: CompanyMatchRule.DEFAULT_REQUIRED_CANDIDATE_SCORE;
@ -42,6 +44,7 @@ public class MatchRuleService {
@Transactional
public Map<String, Object> updateCompanyRule(Long userId, int requiredCandidateScore) {
Company company = requireCompany(userId);
VerifiedCompanyGuard.requireVerified(company);
CompanyMatchRule rule = companyRuleRepository.findByCompanyId(company.getId())
.orElseGet(() -> CompanyMatchRule.builder().companyId(company.getId()).build());
rule.setRequiredCandidateScore(requiredCandidateScore);

2
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java

@ -7,6 +7,7 @@ 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.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import lombok.RequiredArgsConstructor;
@ -107,6 +108,7 @@ public class CandidateScoringEngineService {
public CompanyCandidateScoringResponse getCompanyScoring(Long companyUserId, Long applicationId) {
Company company = companyRepository.findByOwnerUserId(companyUserId)
.orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN));
VerifiedCompanyGuard.requireVerified(company);
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId());

4
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java

@ -11,6 +11,7 @@ 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.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.profile.StudentProfile;
@ -450,8 +451,9 @@ public class CompanyScoringDashboardService {
// ── 辅助 ────────────────────────────────────────────────────
private Company requireCompany(Long userId) {
return companyRepository.findByOwnerUserId(userId)
Company company = companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN));
return VerifiedCompanyGuard.requireVerified(company);
}
private void validatePage(int page, int size) {
if (page < 0 || size < 1 || size > 100) {

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

@ -37,6 +37,7 @@ public enum ErrorCode {
COMPANY_NOT_FOUND(5001, "company not found"),
COMPANY_OWNER_MISMATCH(5002, "company owner mismatch"),
COMPANY_ALREADY_EXISTS(5003, "company already exists"),
COMPANY_NOT_VERIFIED(5004, "company not verified"),
// 学生资料 6xxx
PROFILE_NOT_FOUND(6001, "profile not found"),
@ -239,7 +240,22 @@ public enum ErrorCode {
COMPANY_REGISTRATION_DISABLED(10301, "company registration is disabled"),
COMPANY_REGISTRATION_CONFLICT(10302, "company registration conflict"),
COMPANY_ONBOARDING_INCONSISTENT_STATE(10303, "company onboarding state is inconsistent"),
COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE(10304, "company registration dependency unavailable");
COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE(10304, "company registration dependency unavailable"),
IDENTITY_SELECTION_REQUIRED(10401, "identity selection is required"),
IDENTITY_SELECTION_CONFLICT(10402, "identity selection conflict"),
IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE(10403, "identity selection temporarily unavailable"),
ONBOARDING_CHECKLIST_ITEM_NOT_FOUND(10501, "onboarding checklist item not found"),
ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE(10502, "onboarding checklist item was modified concurrently"),
ONBOARDING_ITEM_LIMIT_EXCEEDED(10503, "onboarding checklist item limit exceeded"),
ONBOARDING_IDEMPOTENCY_CONFLICT(10504, "idempotency key conflict: request body differs"),
ONBOARDING_PROGRESS_CONCURRENT_UPDATE(10505, "onboarding progress was modified concurrently"),
ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE(10506, "company knowledge was modified concurrently"),
SALARY_CITY_NOT_FOUND(10601, "salary city not found or disabled"),
SALARY_INVALID_PARAMETER(10602, "invalid salary calculation parameter"),
SALARY_HISTORY_NOT_FOUND(10603, "salary calculation history not found"),
SALARY_IDEMPOTENCY_CONFLICT(10604, "salary idempotency key conflict: request body differs"),
SALARY_POLICY_VERSION_CONFLICT(10605, "salary city policy version conflict");
private final int code;
private final String message;

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

@ -111,11 +111,16 @@ public final class ErrorHttpStatusRegistry {
return new Builder()
.put(HttpStatus.FORBIDDEN,
ErrorCode.APPLICATION_OWNER_MISMATCH,
ErrorCode.COMPANY_NOT_VERIFIED,
ErrorCode.IDENTITY_SELECTION_REQUIRED,
ErrorCode.FORBIDDEN,
ErrorCode.INTERVIEW_ACCESS_DENIED,
ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION,
ErrorCode.OFFER_COMPARE_NOT_OWNER)
.put(HttpStatus.NOT_FOUND,
ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND,
ErrorCode.SALARY_CITY_NOT_FOUND,
ErrorCode.SALARY_HISTORY_NOT_FOUND,
ErrorCode.ABILITY_ATTEMPT_NOT_FOUND,
ErrorCode.ABILITY_RESULT_NOT_FOUND,
ErrorCode.ABILITY_TYPE_NOT_FOUND,
@ -144,7 +149,15 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.USER_NOT_FOUND,
ErrorCode.VERIFICATION_NOT_FOUND)
.put(HttpStatus.CONFLICT,
ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE,
ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED,
ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT,
ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE,
ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE,
ErrorCode.SALARY_IDEMPOTENCY_CONFLICT,
ErrorCode.SALARY_POLICY_VERSION_CONFLICT,
ErrorCode.PASSWORD_STATE_CONFLICT,
ErrorCode.IDENTITY_SELECTION_CONFLICT,
ErrorCode.ABILITY_ATTEMPT_INVALID_STATE,
ErrorCode.ABILITY_CONCURRENT_UPDATE,
ErrorCode.ABILITY_DUPLICATE_ACTIVE,
@ -226,6 +239,7 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE)
.put(HttpStatus.SERVICE_UNAVAILABLE,
ErrorCode.COMPANY_REGISTRATION_DISABLED,
ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE,
ErrorCode.COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE)
.put(HttpStatus.TOO_MANY_REQUESTS,
ErrorCode.PASSWORD_RATE_LIMITED,

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

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

24
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java

@ -4,8 +4,13 @@ import com.mydreamjob.company.dto.CompanyCreateRequest;
import com.mydreamjob.company.dto.CompanyPublicView;
import com.mydreamjob.company.dto.CompanyUpdateRequest;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.job.dto.CompanyManagedJobItem;
import com.mydreamjob.job.dto.JobSearchItem;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
@ -89,6 +94,25 @@ public class CompanyController {
// ── 公开端:企业岗位列表 ────────────────────────────────────
@Operation(summary = "查询当前企业岗位管理列表",
security = @SecurityRequirement(name = "bearerAuth"),
description = "仅返回当前登录企业自己的岗位,可按状态、标题关键词和城市筛选;未认证企业可查看自己的岗位记录")
@GetMapping("/me/jobs")
public ResponseEntity<ApiResponse<PageResponse<CompanyManagedJobItem>>> getMyJobs(
@Parameter(description = "岗位状态过滤", schema = @Schema(allowableValues = {"DRAFT", "PUBLISHED", "CLOSED"}))
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String city,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Page<CompanyManagedJobItem> result = companyService.getMyJobs(
currentUserAccessor.requireUserId(), status, keyword, city, page, size);
return ResponseEntity.ok(ApiResponse.ok(PageResponse.of(result.getContent(),
result.getTotalElements(), result.getNumber(), result.getSize()),
requestIdAccessor.get(request)));
}
@Operation(summary = "查询企业公开岗位列表", description = "匿名可访问,仅返回 PUBLISHED 岗位")
@GetMapping("/{companyId}/jobs")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyJobs(

10
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java

@ -8,6 +8,7 @@ import com.mydreamjob.company.dto.CompanyCreateRequest;
import com.mydreamjob.company.dto.CompanyPublicView;
import com.mydreamjob.company.dto.CompanyUpdateRequest;
import com.mydreamjob.job.JobService;
import com.mydreamjob.job.dto.CompanyManagedJobItem;
import com.mydreamjob.job.dto.JobSearchItem;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
@ -167,4 +168,13 @@ public class CompanyService {
size // size
);
}
/** 当前企业自身岗位管理列表;身份和企业归属均由服务端解析。 */
public Page<CompanyManagedJobItem> getMyJobs(Long userId, String status,
String keyword, String city,
int page, int size) {
Company company = findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
return jobService.listCompanyJobs(company.getId(), status, keyword, city, page, size);
}
}

41
mydreamjob-server/src/main/java/com/mydreamjob/company/VerifiedCompanyGuard.java

@ -0,0 +1,41 @@
package com.mydreamjob.company;
import com.mydreamjob.auth.User;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import java.util.function.Supplier;
/**
* 企业已认证门禁。角色与企业/资源归属仍由调用 Service 校验;该类只以
* {@code companies.verified} 为准判定企业招聘业务是否可继续。
*/
public final class VerifiedCompanyGuard {
private VerifiedCompanyGuard() {
}
/** 认证前的公司身份检查已通过后,调用本方法执行唯一的认证状态判定。 */
public static Company requireVerified(Company company) {
if (company == null) {
throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
}
if (!Boolean.TRUE.equals(company.getVerified())) {
throw new BusinessException(ErrorCode.COMPANY_NOT_VERIFIED);
}
return company;
}
/** 公司资料缺失、尚无审核申请或被拒绝,都统一按未认证企业拒绝。 */
public static boolean isVerified(Company company) {
return company != null && Boolean.TRUE.equals(company.getVerified());
}
/** 对学生等非企业调用方保持原语义;企业身份则失败关闭。 */
public static void requireVerifiedIfCompany(User actor, Supplier<Company> companySupplier) {
if (actor != null && actor.getRoles() != null
&& actor.getRoles().contains(User.Role.COMPANY)) {
requireVerified(companySupplier.get());
}
}
}

90
mydreamjob-server/src/main/java/com/mydreamjob/config/MySql57MigrationConfiguration.java

@ -0,0 +1,90 @@
package com.mydreamjob.config;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.nio.charset.Charset;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import org.flywaydb.core.api.ResourceProvider;
import org.flywaydb.core.api.configuration.FluentConfiguration;
import org.flywaydb.core.api.resource.LoadableResource;
import org.springframework.boot.autoconfigure.flyway.FlywayConfigurationCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
/** 仅替换 MySQL 5.7 的 V49 资源;既有 SQL、其他数据库及历史 checksum 不改写。 */
@Configuration(proxyBeanMethods = false)
public class MySql57MigrationConfiguration {
private static final String V49 = "V49__skill_cloud_boolean_type.sql";
@Bean
FlywayConfigurationCustomizer mysql57MigrationResources() {
return MySql57MigrationConfiguration::configure;
}
public static void configure(FluentConfiguration configuration) {
try (Connection connection = configuration.getDataSource().getConnection()) {
DatabaseMetaData metadata = connection.getMetaData();
if (!"MySQL".equals(metadata.getDatabaseProductName())
|| metadata.getDatabaseMajorVersion() != 5
|| metadata.getDatabaseMinorVersion() != 7) {
return;
}
// 不静默忽略调用者的自定义迁移路径或资源提供器。
if (configuration.getResourceProvider() != null
|| configuration.getLocations().length != 1
|| !"classpath:db/migration".equals(configuration.getLocations()[0].getDescriptor())) {
throw new IllegalStateException("MySQL 5.7 migration configuration requires the standard migration location");
}
configuration.resourceProvider(resources(configuration.getEncoding()));
} catch (SQLException | IOException ex) {
// 不把连接 URL、账号、驱动异常原文带进启动日志。
throw new IllegalStateException("Database migration compatibility initialization failed");
}
}
private static ResourceProvider resources(Charset encoding) throws IOException {
Map<String, LoadableResource> resources = new LinkedHashMap<>();
Resource[] originals = new PathMatchingResourcePatternResolver()
.getResources("classpath*:db/migration/*");
for (Resource original : originals) {
String filename = original.getFilename();
if (filename == null || !original.isReadable()) continue;
Resource selected = V49.equals(filename)
? new ClassPathResource("db/mysql57/" + V49) : original;
String text = selected.getContentAsString(encoding);
String path = selected.getURL().toExternalForm();
LoadableResource resource = new LoadableResource() {
@Override public Reader read() { return new StringReader(text); }
@Override public String getAbsolutePath() { return path; }
@Override public String getAbsolutePathOnDisk() { return path; }
@Override public String getFilename() { return filename; }
@Override public String getRelativePath() { return "db/migration/" + filename; }
};
if (resources.putIfAbsent(filename, resource) != null) {
throw new IllegalStateException("Duplicate database migration resource");
}
}
if (!resources.containsKey(V49)) throw new IllegalStateException("Required database migration resource missing");
return new ResourceProvider() {
@Override public LoadableResource getResource(String name) {
return resources.values().stream().filter(resource -> name.equals(resource.getFilename())
|| name.equals(resource.getRelativePath()) || name.equals(resource.getAbsolutePath()))
.findFirst().orElse(null);
}
@Override public Collection<LoadableResource> getResources(String prefix, String[] suffixes) {
return resources.values().stream().filter(resource -> resource.getFilename().startsWith(prefix)
&& Arrays.stream(suffixes).anyMatch(resource.getFilename()::endsWith)).toList();
}
};
}
}

17
mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java

@ -1,6 +1,8 @@
package com.mydreamjob.config;
import com.mydreamjob.auth.JwtAuthenticationFilter;
import com.mydreamjob.auth.identity.IdentityAccessFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import com.mydreamjob.audit.AuditService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpMethod;
@ -36,6 +38,14 @@ public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final AuditService auditService;
private final IdentityAccessFilter identityAccessFilter;
@Bean
public FilterRegistrationBean<IdentityAccessFilter> identityFilterRegistration() {
var registration = new FilterRegistrationBean<>(identityAccessFilter);
registration.setEnabled(false); // 只在 JWT 验证后的安全链执行。
return registration;
}
@Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:3000}")
private String allowedOrigins;
@ -90,6 +100,7 @@ public class SecurityConfig {
// S02:登出必须已认证
.requestMatchers("/auth/logout").authenticated()
.requestMatchers("/auth/password/set").authenticated()
.requestMatchers("/auth/password/setup").authenticated()
.requestMatchers("/auth/password/change").authenticated()
// 企业资料(需登录)
.requestMatchers("/companies/me/**").authenticated()
@ -154,7 +165,8 @@ public class SecurityConfig {
.requestMatchers("/").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(identityAccessFilter, JwtAuthenticationFilter.class);
return http.build();
}
@ -170,7 +182,8 @@ public class SecurityConfig {
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
// 匹配的是去掉 servlet context-path 后的应用路径,来源仍由白名单限定。
source.registerCorsConfiguration("/**", config);
return source;
}

67
mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java

@ -16,6 +16,9 @@ import com.mydreamjob.auth.password.PasswordAuthenticationService;
import com.mydreamjob.auth.company.CompanyRegistrationRequest;
import com.mydreamjob.auth.company.CompanyRegistrationResponse;
import com.mydreamjob.auth.company.CompanyRegistrationService;
import com.mydreamjob.auth.identity.IdentitySelectionService;
import com.mydreamjob.auth.identity.IdentitySelectionRequest;
import com.mydreamjob.auth.identity.IdentitySelectionResponse;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
@ -55,6 +58,44 @@ public class AuthController {
private final CurrentUserAccessor currentUserAccessor;
private final PasswordAuthenticationService passwordAuthenticationService;
private final CompanyRegistrationService companyRegistrationService;
private final IdentitySelectionService identitySelectionService;
private final com.mydreamjob.auth.password.PasswordSetupService passwordSetupService;
@Operation(summary = "短信登录后首次设置密码", description = "需要当前 Access Token 和 10 分钟一次性设置凭证;成功后必须替换旧 Token")
@PostMapping("/password/setup")
public ResponseEntity<ApiResponse<com.mydreamjob.auth.dto.PasswordSetupResponse>> setupPassword(
@Valid @RequestBody com.mydreamjob.auth.dto.PasswordSetupRequest body, HttpServletRequest request) {
try {
var result = passwordSetupService.setup(currentUserAccessor.requireUserId(),
currentUserAccessor.requireSessionId(), body.passwordSetupToken(), body.newPassword(), AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(result, requestIdAccessor.get(request)));
} catch (InvalidTokenException e) { return unauthorized(request); }
catch (BusinessException e) {
if (e.getErrorCodeInt() == ErrorCode.SERVICE_UNAVAILABLE.getCode()) {
return ResponseEntity.status(503).body(ApiResponse.error(503,
"authentication service temporarily unavailable", requestIdAccessor.get(request)));
}
throw e;
}
catch (org.springframework.dao.DataAccessException | org.springframework.transaction.TransactionException e) {
return ResponseEntity.status(503).body(ApiResponse.error(503,
"authentication service temporarily unavailable", requestIdAccessor.get(request)));
}
}
@Operation(summary = "首次选择账号身份", description = "统一账号一次性选择求职者或企业,成功后替换旧登录凭证")
@PostMapping("/identity/select")
public ResponseEntity<ApiResponse<IdentitySelectionResponse>> selectIdentity(
@Valid @RequestBody IdentitySelectionRequest body, HttpServletRequest request) {
try {
var response = identitySelectionService.select(currentUserAccessor.requireUserId(),
currentUserAccessor.requireSessionId(), body, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request)));
} catch (InvalidTokenException e) {
// 过滤器校验后仍可能被并发选择/登出撤销,沿用认证接口的统一安全响应。
return unauthorized(request);
}
}
@Operation(summary = "企业注册", description = "使用 company-register 短信验证码创建企业账号和企业资料")
@PostMapping("/company/register")
@ -64,31 +105,21 @@ public class AuthController {
return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request)));
}
@Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。")
@Operation(summary = "发送短信验证码", description = "真实发送使用阿里云短信网关;Mock 仅供开发测试。网关受理不等于手机送达,验证码不会写入日志。")
@PostMapping("/sms/send")
public ResponseEntity<ApiResponse<Map<String, String>>> sendSmsCode(
@RequestParam @NotBlank(message = "phone is required")
@Pattern(regexp = "[0-9]{11}", message = "phone must contain exactly 11 digits") String phone,
@RequestParam(defaultValue = "login") String purpose,
HttpServletRequest request) {
try {
smsVerificationService.sendCode(phone, purpose,
requestIdAccessor.get(request),
request.getRemoteAddr(),
request.getHeader("User-Agent"));
return ResponseEntity.ok(ApiResponse.ok(
Map.of("message", "验证码已发送(Mock)"),
requestIdAccessor.get(request), request.getRemoteAddr(), request.getHeader("User-Agent"));
// 保留业务码,统一走异常映射;不把 SDK 或 SQL 原始异常信息写回客户端。
return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "验证码发送请求已受理"),
requestIdAccessor.get(request)));
} catch (ConstraintViolationException e) {
return ResponseEntity.badRequest().body(
ApiResponse.badRequest(e.getMessage(), requestIdAccessor.get(request)));
} catch (Exception e) {
return ResponseEntity.badRequest().body(
ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request)));
}
}
@Operation(summary = "Mock 登录", description = "使用手机号+验证码登录,返回 Access/Refresh Token 并创建服务端登录会话")
@Operation(summary = "手机号验证码登录", description = "新手机号创建待选身份统一账号;已有账号保持身份,返回 Access/Refresh Token")
@PostMapping("/login")
public ResponseEntity<ApiResponse<Map<String, Object>>> mockLogin(
@Valid @RequestBody LoginRequest body,
@ -112,8 +143,8 @@ public class AuthController {
return ResponseEntity.badRequest().body(
ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request)));
} catch (Exception e) {
return ResponseEntity.badRequest().body(
ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request)));
return ResponseEntity.status(503).body(
ApiResponse.error(503, "authentication service temporarily unavailable", requestIdAccessor.get(request)));
}
}
@ -240,7 +271,7 @@ public class AuthController {
return ResponseEntity.ok(ApiResponse.ok(authService.getCurrentUser(userId), requestIdAccessor.get(request)));
}
private ResponseEntity<ApiResponse<TokenPairResponse>> unauthorized(HttpServletRequest request) {
private <T> ResponseEntity<ApiResponse<T>> unauthorized(HttpServletRequest request) {
return ResponseEntity.status(401).body(
ApiResponse.unauthorized("invalid or expired token", requestIdAccessor.get(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.UserRepository;
import com.mydreamjob.dataexport.PersonalDataExportCleanupService;
import com.mydreamjob.salarycalculator.SalaryHistoryCleanupService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
@ -35,6 +36,7 @@ public class AccountDeletionCompletionWorker {
private final AuditService auditService;
private final AccountDeletionPhoneHasher phoneHasher;
private final PersonalDataExportCleanupService dataExportCleanupService;
private final SalaryHistoryCleanupService salaryHistoryCleanupService;
private final AuthSessionService authSessionService;
private final Clock clock;
@ -68,6 +70,7 @@ public class AccountDeletionCompletionWorker {
rec.getUserId(), AuthSessionService.REASON_ACCOUNT_DELETED, capturedNow);
// 注销完成:清理该用户的个人导出文件并作废未完成任务
dataExportCleanupService.purgeForDeletedUser(rec.getUserId());
salaryHistoryCleanupService.purgeForDeletedUser(rec.getUserId());
// 完成 + 脱敏审计各写一次
auditService.log(userId, "ACCOUNT_DELETION_COMPLETED", "ACCOUNT_DELETION",
rec.getId(), "account deletion completed; userId=" + userId

28
mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java

@ -6,6 +6,7 @@ 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.company.VerifiedCompanyGuard;
import com.mydreamjob.feedback.dto.CandidateFeedbackRequest;
import com.mydreamjob.feedback.dto.CandidateFeedbackResponse;
import com.mydreamjob.job.Job;
@ -47,11 +48,16 @@ public class CandidateFeedbackService {
* 企业归属校验:先按当前登录 userId 解析其拥有的企业,
* 再将企业 id 与 job.companyId 比较(userId 与 companyId 属不同命名空间,不可直接比较)。
*/
private void assertCompanyOwner(Long userId, Job job) {
private Company assertCompanyOwner(Long userId, Job job) {
Company company = companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN));
if (!company.getId().equals(job.getCompanyId()))
throw new BusinessException(ErrorCode.FORBIDDEN);
return company;
}
private void requireOwnedCompanyVerifiedIfPresent(Long userId) {
companyRepository.findByOwnerUserId(userId).ifPresent(VerifiedCompanyGuard::requireVerified);
}
private void assertStudentOwner(Long studentUserId, Application app) {
@ -63,9 +69,11 @@ public class CandidateFeedbackService {
@Transactional
public CandidateFeedbackResponse createFeedback(Long userId, Long applicationId, CandidateFeedbackRequest req) {
requireOwnedCompanyVerifiedIfPresent(userId);
Application app = findApplication(applicationId);
Job job = findJob(app);
assertCompanyOwner(userId, job);
Company company = assertCompanyOwner(userId, job);
VerifiedCompanyGuard.requireVerified(company);
if (feedbackRepository.existsByApplicationId(applicationId))
throw new BusinessException(ErrorCode.DUPLICATE_FEEDBACK);
@ -86,14 +94,18 @@ public class CandidateFeedbackService {
// ── 查询 ──────────────────────────────────────────────────
public CandidateFeedbackResponse getFeedback(Long userId, Long applicationId) {
requireOwnedCompanyVerifiedIfPresent(userId);
Application app = findApplication(applicationId);
Job job = findJob(app);
// 企业(按 userId 解析归属企业后比较企业 id)或学生任一匹配即可查看
boolean isStudent = app.getStudentUserId().equals(userId);
boolean isCompany = companyRepository.findByOwnerUserId(userId)
.map(c -> c.getId().equals(job.getCompanyId()))
.orElse(false);
.map(c -> {
if (!c.getId().equals(job.getCompanyId())) return false;
VerifiedCompanyGuard.requireVerified(c);
return true;
}).orElse(false);
if (!isCompany && !isStudent)
throw new BusinessException(ErrorCode.FORBIDDEN);
@ -106,9 +118,11 @@ public class CandidateFeedbackService {
@Transactional
public CandidateFeedbackResponse updateFeedback(Long userId, Long applicationId, CandidateFeedbackRequest req) {
requireOwnedCompanyVerifiedIfPresent(userId);
Application app = findApplication(applicationId);
Job job = findJob(app);
assertCompanyOwner(userId, job);
Company company = assertCompanyOwner(userId, job);
VerifiedCompanyGuard.requireVerified(company);
CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND));
@ -126,9 +140,11 @@ public class CandidateFeedbackService {
@Transactional
public void deleteFeedback(Long userId, Long applicationId) {
requireOwnedCompanyVerifiedIfPresent(userId);
Application app = findApplication(applicationId);
Job job = findJob(app);
assertCompanyOwner(userId, job);
Company company = assertCompanyOwner(userId, job);
VerifiedCompanyGuard.requireVerified(company);
CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND));

25
mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewService.java

@ -10,6 +10,7 @@ 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.company.VerifiedCompanyGuard;
import com.mydreamjob.interview.dto.InterviewCancelRequest;
import com.mydreamjob.interview.dto.InterviewCreateRequest;
import com.mydreamjob.interview.dto.InterviewRespondRequest;
@ -60,6 +61,7 @@ public class InterviewService {
public Interview createInterview(Long operatorUserId, Long applicationId, InterviewCreateRequest req,
AuditContext ctx) {
Company company = requireCompany(operatorUserId);
VerifiedCompanyGuard.requireVerified(company);
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
@ -101,6 +103,7 @@ public class InterviewService {
// ── 查询某投递的面试列表 ─────────────────────────────────────
public List<Interview> listByApplication(Long applicationId, Long userId) {
requireVerifiedIfCompany(userId);
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
assertApplicationAccess(app, userId);
@ -119,6 +122,7 @@ public class InterviewService {
boolean hasRange = from != null && to != null;
if (hasRole(user, User.Role.COMPANY)) {
VerifiedCompanyGuard.requireVerified(requireCompany(userId));
return listByCompanyUser(userId, status, from, to, pr);
}
if (hasRole(user, User.Role.STUDENT)) {
@ -158,6 +162,7 @@ public class InterviewService {
// ── 查看面试详情 ────────────────────────────────────────────
public Interview getInterview(Long interviewId, Long userId) {
requireVerifiedIfCompany(userId);
Interview interview = requireInterview(interviewId);
assertViewAccess(interview, userId);
return interview;
@ -169,6 +174,7 @@ public class InterviewService {
public Interview updateInterview(Long operatorUserId, Long interviewId, InterviewUpdateRequest req,
AuditContext ctx) {
Company company = requireCompany(operatorUserId);
VerifiedCompanyGuard.requireVerified(company);
Interview interview = requireInterview(interviewId);
if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) {
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
@ -253,6 +259,7 @@ public class InterviewService {
public Interview cancelInterview(Long operatorUserId, Long interviewId, InterviewCancelRequest req,
AuditContext ctx) {
Company company = requireCompany(operatorUserId);
VerifiedCompanyGuard.requireVerified(company);
Interview interview = requireInterview(interviewId);
if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) {
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
@ -297,6 +304,7 @@ public class InterviewService {
@Transactional
public Interview completeInterview(Long operatorUserId, Long interviewId, String notes, AuditContext ctx) {
Company company = requireCompany(operatorUserId);
VerifiedCompanyGuard.requireVerified(company);
Interview interview = requireInterview(interviewId);
if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) {
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
@ -411,17 +419,30 @@ public class InterviewService {
private void assertViewAccess(Interview interview, Long userId) {
User user = loadUser(userId);
if (hasRole(user, User.Role.STUDENT) && interview.getStudentUserId().equals(userId)) return;
if (hasRole(user, User.Role.COMPANY) && interview.getCompanyUserId().equals(userId)) return;
if (hasRole(user, User.Role.COMPANY) && interview.getCompanyUserId().equals(userId)) {
VerifiedCompanyGuard.requireVerified(requireCompany(userId));
return;
}
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
}
private void requireVerifiedIfCompany(Long userId) {
User user = userRepository.findById(userId).orElse(null);
if (user != null && hasRole(user, User.Role.COMPANY)) {
VerifiedCompanyGuard.requireVerified(requireCompany(userId));
}
}
private void assertApplicationAccess(Application app, Long userId) {
User user = loadUser(userId);
if (hasRole(user, User.Role.STUDENT) && app.getStudentUserId().equals(userId)) return;
if (hasRole(user, User.Role.COMPANY)) {
Job job = jobService.getJob(app.getJobId());
Company company = companyRepository.findByOwnerUserId(userId).orElse(null);
if (company != null && company.getId().equals(job.getCompanyId())) return;
if (company != null && company.getId().equals(job.getCompanyId())) {
VerifiedCompanyGuard.requireVerified(company);
return;
}
}
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
}

16
mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreService.java

@ -11,6 +11,7 @@ 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.company.VerifiedCompanyGuard;
import com.mydreamjob.interview.Interview;
import com.mydreamjob.interview.InterviewRepository;
import com.mydreamjob.interviewscore.dto.InterviewScoreHistoryItem;
@ -133,8 +134,9 @@ public class InterviewScoreService {
@Transactional(readOnly = true)
public InterviewScoreResponse getScore(Long userId, Long interviewId) {
Interview interview = requireInterview(interviewId);
User user = loadUser(userId);
if (hasRole(user, User.Role.COMPANY)) requireVerifiedScoringCompany(userId);
Interview interview = requireInterview(interviewId);
if (hasRole(user, User.Role.COMPANY)) {
requireScoringCompany(interviewId, userId);
@ -209,8 +211,9 @@ public class InterviewScoreService {
@Transactional(readOnly = true)
public List<InterviewScoreHistoryItem> getHistory(Long userId, Long interviewId) {
Interview interview = requireInterview(interviewId);
User user = loadUser(userId);
if (hasRole(user, User.Role.COMPANY)) requireVerifiedScoringCompany(userId);
Interview interview = requireInterview(interviewId);
if (hasRole(user, User.Role.COMPANY)) {
requireScoringCompany(interviewId, userId);
@ -238,9 +241,8 @@ public class InterviewScoreService {
if (!hasRole(user, User.Role.COMPANY)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
Company company = requireVerifiedScoringCompany(userId);
Interview interview = requireInterview(interviewId);
Company company = companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
// 1. 当前企业的 owner 必须是该面试的创建企业
if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) {
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
@ -259,6 +261,12 @@ public class InterviewScoreService {
return company;
}
private Company requireVerifiedScoringCompany(Long userId) {
Company company = companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
return VerifiedCompanyGuard.requireVerified(company);
}
private void requireCompleted(Interview interview) {
if (!"COMPLETED".equals(interview.getStatus())) {
throw new BusinessException(ErrorCode.INTERVIEW_NOT_COMPLETED);

30
mydreamjob-server/src/main/java/com/mydreamjob/job/CompanyJobManagementSpecification.java

@ -0,0 +1,30 @@
package com.mydreamjob.job;
import jakarta.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.springframework.data.jpa.domain.Specification;
/** 企业私有岗位列表查询;租户条件始终是必选条件。 */
public final class CompanyJobManagementSpecification {
private CompanyJobManagementSpecification() {}
public static Specification<Job> forCompany(long companyId, String status, String keyword, String city) {
return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(root.get("companyId"), companyId));
if (status != null) predicates.add(cb.equal(root.get("status"), status));
if (keyword != null) {
// LOCATE treats % and _ literally and is supported by both H2 and MySQL.
predicates.add(cb.greaterThan(cb.locate(
cb.lower(root.get("title")), keyword.toLowerCase(Locale.ROOT)), 0));
}
if (city != null) {
predicates.add(cb.equal(cb.lower(root.get("city")), city.toLowerCase(Locale.ROOT)));
}
return cb.and(predicates.toArray(Predicate[]::new));
};
}
}

4
mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java

@ -85,9 +85,7 @@ public class JobController {
@PathVariable Long jobId,
HttpServletRequest request) {
Long companyId = resolveCompanyId();
// 验证归属后返回
jobService.assertOwnerOrThrow(companyId, jobId);
Map<String, Object> view = jobService.toView(jobService.getJob(jobId));
Map<String, Object> view = jobService.toView(jobService.getCompanyJob(companyId, jobId));
return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request)));
}

4
mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java

@ -9,10 +9,14 @@ import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface JobRepository extends JpaRepository<Job, Long>, JpaSpecificationExecutor<Job> {
/** 企业资源访问使用归属限定查询,避免取出其他企业的岗位后再区分归属。 */
Optional<Job> findByIdAndCompanyId(Long id, Long companyId);
List<Job> findByCompanyIdAndStatusOrderByCreatedAtDesc(Long companyId, String status);
List<Job> findByStatusOrderByCreatedAtDesc(String status);

63
mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java

@ -4,10 +4,12 @@ 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.company.VerifiedCompanyGuard;
import com.mydreamjob.job.dto.JobCreateRequest;
import com.mydreamjob.job.dto.JobSearchItem;
import com.mydreamjob.job.dto.JobStatusRequest;
import com.mydreamjob.job.dto.JobUpdateRequest;
import com.mydreamjob.job.dto.CompanyManagedJobItem;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.TypedQuery;
@ -75,7 +77,10 @@ public class JobService {
*/
@Transactional
public Job updateJob(Long companyId, Long jobId, JobUpdateRequest req) {
Job job = assertOwnerOrThrow(companyId, jobId);
Job job = getCompanyJob(companyId, jobId);
if (!JobStatus.DRAFT.name().equals(job.getStatus())) {
requireVerifiedCompany(companyId);
}
if (req.getTitle() != null) job.setTitle(req.getTitle());
if (req.getDescription() != null) job.setDescription(req.getDescription());
@ -93,10 +98,16 @@ public class JobService {
*/
@Transactional
public Job transitionStatus(Long companyId, Long jobId, JobStatusRequest req) {
Job job = assertOwnerOrThrow(companyId, jobId);
String current = job.getStatus();
String target = req.getStatus();
// 未认证企业的发布请求必须在按 jobId 查询前拒绝,避免通过 400/404 差异探测岗位。
if (JobStatus.PUBLISHED.name().equals(target)) {
requireVerifiedCompany(companyId);
}
Job job = getCompanyJob(companyId, jobId);
String current = job.getStatus();
// 校验状态流转合法性
boolean valid = switch (current) {
case "DRAFT" -> "PUBLISHED".equals(target);
@ -115,6 +126,52 @@ public class JobService {
return jobRepository.save(job);
}
/** 用企业主键解析权威认证状态,供岗位与投递服务在已确认归属后执行统一门禁。 */
public Company requireVerifiedCompany(Long companyId) {
return VerifiedCompanyGuard.requireVerified(
companyRepository.findById(companyId).orElse(null));
}
/**
* 读取当前企业自己的岗位;对外统一将其他企业资源与不存在资源表示为 JOB_NOT_FOUND,
* 不向调用方泄露跨企业资源是否存在。
*/
public Job getCompanyJob(Long companyId, Long jobId) {
return jobRepository.findByIdAndCompanyId(jobId, companyId)
.orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND));
}
/** 查询当前企业自己的全部岗位状态;不调用发布门禁,也不产生写入副作用。 */
@Transactional(readOnly = true)
public Page<CompanyManagedJobItem> listCompanyJobs(Long companyId, String status,
String keyword, String city,
int page, int size) {
if (companyId == null || companyId <= 0 || page < 0 || size < 1 || size > 100) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (status != null) {
try {
JobStatus.valueOf(status);
} catch (IllegalArgumentException exception) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
}
String normalizedKeyword = normalizeFilter(keyword, 120);
String normalizedCity = normalizeFilter(city, 80);
Pageable pageable = PageRequest.of(page, size, Sort.by(
Sort.Order.desc("createdAt"), Sort.Order.desc("id")));
return jobRepository.findAll(CompanyJobManagementSpecification.forCompany(
companyId, status, normalizedKeyword, normalizedCity), pageable)
.map(CompanyManagedJobItem::from);
}
private String normalizeFilter(String value, int maxLength) {
if (value == null || value.isBlank()) return null;
String normalized = value.trim();
if (normalized.length() > maxLength) throw new BusinessException(ErrorCode.BAD_REQUEST);
return normalized;
}
// ── 公开接口 ────────────────────────────────────────────────
/**

30
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/CompanyManagedJobItem.java

@ -0,0 +1,30 @@
package com.mydreamjob.job.dto;
import com.mydreamjob.job.Job;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
/** 企业自身岗位管理列表项,不包含招聘统计、候选人或内部审核数据。 */
@Schema(name = "CompanyManagedJobItem", description = "企业自身岗位管理安全摘要")
public record CompanyManagedJobItem(
@Schema(description = "岗位 ID") Long id,
@Schema(description = "所属企业 ID") Long companyId,
@Schema(description = "岗位名称") String title,
@Schema(description = "岗位描述") String description,
@Schema(description = "任职要求") String requirements,
@Schema(description = "工作城市") String city,
@Schema(description = "用工类型") String employmentType,
@Schema(description = "最低月薪,单位元") Integer salaryMin,
@Schema(description = "最高月薪,单位元") Integer salaryMax,
@Schema(description = "岗位状态:DRAFT、PUBLISHED、CLOSED") String status,
@Schema(description = "发布时间") LocalDateTime publishedAt,
@Schema(description = "创建时间") LocalDateTime createdAt,
@Schema(description = "更新时间") LocalDateTime updatedAt) {
public static CompanyManagedJobItem from(Job job) {
return new CompanyManagedJobItem(job.getId(), job.getCompanyId(), job.getTitle(),
job.getDescription(), job.getRequirements(), job.getCity(), job.getEmploymentType(),
job.getSalaryMin(), job.getSalaryMax(), job.getStatus(), job.getPublishedAt(),
job.getCreatedAt(), job.getUpdatedAt());
}
}

14
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java

@ -12,6 +12,7 @@ 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.company.VerifiedCompanyGuard;
import com.mydreamjob.messaging.dto.CreateConversationRequest;
import com.mydreamjob.messaging.dto.MessagePageResponse;
import com.mydreamjob.messaging.dto.MessageResponse;
@ -68,6 +69,7 @@ public class MessagingService {
}
public List<Map<String, Object>> listConversations(Long userId) {
requireVerifiedIfCompany(userId);
List<ConversationMember> memberships = memberRepository.findByUserId(userId);
if (memberships.isEmpty()) return List.of();
List<Long> convIds = memberships.stream().map(ConversationMember::getConversationId).toList();
@ -86,6 +88,7 @@ public class MessagingService {
}
public Map<String, Object> getConversation(Long conversationId, Long userId) {
requireVerifiedIfCompany(userId);
Conversation c = requireConversation(conversationId);
assertMember(conversationId, userId);
Map<String, Object> v = toView(c, userId);
@ -95,6 +98,7 @@ public class MessagingService {
@Transactional
public Map<String, Object> createOrGetDirectConversation(Long userId, CreateConversationRequest req) {
requireVerifiedIfCompany(userId);
Long otherId = req.getParticipantUserId();
if (userId.equals(otherId)) throw new BusinessException(ErrorCode.BAD_REQUEST);
if (!userRepository.existsById(otherId)) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
@ -146,6 +150,12 @@ public class MessagingService {
return user.getRoles() != null && user.getRoles().stream().anyMatch(r -> r == role);
}
private void requireVerifiedIfCompany(Long userId) {
User actor = userRepository.findByIdWithRoles(userId).orElse(null);
VerifiedCompanyGuard.requireVerifiedIfCompany(actor,
() -> companyRepository.findByOwnerUserId(userId).orElse(null));
}
// ── V33 屏蔽控制:任一方向存在屏蔽即禁止站内信交互 ──
private boolean isBlocked(Long aId, Long bId) {
@ -177,6 +187,7 @@ public class MessagingService {
@Transactional
public Map<String, Object> sendMessage(Long userId, SendMessageRequest req) {
requireVerifiedIfCompany(userId);
requireConversation(req.getConversationId());
assertMember(req.getConversationId(), userId);
if (req.getContent().length() > MAX_CONTENT_LENGTH) throw new BusinessException(ErrorCode.BAD_REQUEST);
@ -204,6 +215,7 @@ public class MessagingService {
}
public MessagePageResponse listMessages(Long conversationId, Long userId, int page, int size) {
requireVerifiedIfCompany(userId);
Conversation conversation = requireConversation(conversationId);
assertMember(conversationId, userId);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST);
@ -230,6 +242,7 @@ public class MessagingService {
@Transactional
public void markRead(Long conversationId, Long userId) {
requireVerifiedIfCompany(userId);
requireConversation(conversationId);
assertMember(conversationId, userId);
List<Message> msgs = messageRepository
@ -265,6 +278,7 @@ public class MessagingService {
@Transactional
public MessageResponse recallMessage(Long currentUserId, Long conversationId, Long messageId,
AuditContext auditContext, LocalDateTime capturedNow) {
requireVerifiedIfCompany(currentUserId);
Conversation conversation = requireConversation(conversationId);
assertMember(conversationId, currentUserId);
User currentUser = userRepository.findById(currentUserId).orElse(null);

16
mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationService.java

@ -1,7 +1,11 @@
package com.mydreamjob.notification;
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.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
@ -22,6 +26,8 @@ public class NotificationService {
private final NotificationRepository notificationRepository;
private final PushProvider pushProvider;
private final NotificationPublisher notificationPublisher;
private final UserRepository userRepository;
private final CompanyRepository companyRepository;
private static final int MAX_PAGE_SIZE = 50;
@ -74,6 +80,7 @@ public class NotificationService {
/** 分页查询用户通知 */
public Page<Notification> list(Long userId, int page, int size) {
requireVerifiedIfCompany(userId);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST);
return notificationRepository.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, size));
}
@ -81,6 +88,7 @@ public class NotificationService {
/** 标记单条已读(幂等:已读也返回成功) */
@Transactional
public void markRead(Long userId, Long notificationId) {
requireVerifiedIfCompany(userId);
Notification n = notificationRepository.findById(notificationId)
.orElseThrow(() -> new BusinessException(ErrorCode.NOTIFICATION_NOT_FOUND));
if (!n.getUserId().equals(userId)) throw new BusinessException(ErrorCode.FORBIDDEN);
@ -94,6 +102,7 @@ public class NotificationService {
/** 标记全部已读(幂等) */
@Transactional
public int markAllRead(Long userId) {
requireVerifiedIfCompany(userId);
int count = 0;
int pageIdx = 0;
boolean hasMore = true;
@ -119,9 +128,16 @@ public class NotificationService {
/** 未读数 */
public long unreadCount(Long userId) {
requireVerifiedIfCompany(userId);
return notificationRepository.countUnreadByUserId(userId);
}
private void requireVerifiedIfCompany(Long userId) {
User actor = userRepository.findByIdWithRoles(userId).orElse(null);
VerifiedCompanyGuard.requireVerifiedIfCompany(actor,
() -> companyRepository.findByOwnerUserId(userId).orElse(null));
}
public Map<String, Object> toView(Notification n) {
Map<String, Object> v = new HashMap<>();
v.put("id", n.getId());

25
mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java

@ -10,6 +10,7 @@ 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.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.notification.NotificationService;
@ -153,8 +154,7 @@ public class OfferService {
PageRequest pr = PageRequest.of(page, size);
if (hasRole(user, User.Role.COMPANY)) {
Company company = companyRepository.findByOwnerUserId(userId).orElse(null);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
Company company = requireCompany(userId);
return listByCompany(company.getId(), status, applicationId, pr);
}
if (hasRole(user, User.Role.STUDENT)) {
@ -189,6 +189,7 @@ public class OfferService {
/** 详情:仅目标候选人、所属企业可访问 */
public Offer getOffer(Long offerId, Long userId) {
requireVerifiedIfCompany(userId);
Offer offer = requireOffer(offerId);
assertViewAccess(offer, userId);
return offer;
@ -258,17 +259,18 @@ public class OfferService {
*/
@Transactional
public Offer transitionStatus(Long operatorUserId, Long offerId, OfferStatusRequest req, AuditContext ctx) {
Offer offer = requireOffer(offerId);
User user = loadUser(operatorUserId);
boolean isCompany = hasRole(user, User.Role.COMPANY);
boolean isCandidate = hasRole(user, User.Role.STUDENT);
ActorRole actor = isCompany ? ActorRole.COMPANY : (isCandidate ? ActorRole.CANDIDATE : null);
if (actor == null) throw new BusinessException(ErrorCode.FORBIDDEN);
Company currentCompany = actor == ActorRole.COMPANY ? requireCompany(operatorUserId) : null;
Offer offer = requireOffer(offerId);
// 访问控制(身份一律来自 JWT)
if (actor == ActorRole.COMPANY) {
Company company = companyRepository.findByOwnerUserId(operatorUserId).orElse(null);
if (company == null || !company.getId().equals(offer.getCompanyId())) {
if (!currentCompany.getId().equals(offer.getCompanyId())) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
} else {
@ -794,8 +796,12 @@ public class OfferService {
if (!hasRole(user, User.Role.COMPANY)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
return VerifiedCompanyGuard.requireVerified(companyRepository.findByOwnerUserId(userId).orElse(null));
}
private void requireVerifiedIfCompany(Long userId) {
User user = userRepository.findById(userId).orElse(null);
if (user != null && hasRole(user, User.Role.COMPANY)) requireCompany(userId);
}
private boolean hasRole(User user, User.Role role) {
@ -807,7 +813,10 @@ public class OfferService {
if (hasRole(user, User.Role.STUDENT) && offer.getCandidateId().equals(userId)) return;
if (hasRole(user, User.Role.COMPANY)) {
Company company = companyRepository.findByOwnerUserId(userId).orElse(null);
if (company != null && company.getId().equals(offer.getCompanyId())) return;
if (company != null && company.getId().equals(offer.getCompanyId())) {
VerifiedCompanyGuard.requireVerified(company);
return;
}
}
throw new BusinessException(ErrorCode.FORBIDDEN);
}

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

8
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java

@ -11,6 +11,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyService;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
import com.mydreamjob.job.JobService;
@ -206,7 +207,12 @@ public class JobRecommendationService {
private String write(Object o){try{return o==null?null:JSON.writeValueAsString(o);}catch(Exception e){throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);}}
private void validateTaxonomy(String industry,String family){JobRecommendationTaxonomy i=taxonomyRepository.findById(industry).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));JobRecommendationTaxonomy f=taxonomyRepository.findById(family).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));if(!Boolean.TRUE.equals(i.getEnabled())||!Boolean.TRUE.equals(f.getEnabled())||!"INDUSTRY".equals(i.getTaxonomyType())||!"JOB_FAMILY".equals(f.getTaxonomyType())||!industry.equals(f.getParentCode()))throw new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID);}
private void validateMaps(Map<String,Integer> r,Map<String,Integer>a){if((r==null||r.isEmpty())&&(a==null||a.isEmpty()))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(r!=null&&(!RIASEC.containsAll(r.keySet())||r.values().stream().anyMatch(v->v==null||v<0||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(a!=null&&(!ABILITY.containsAll(a.keySet())||a.values().stream().anyMatch(v->v==null||v<1||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);}
private void assertCompanyOwns(Long user,Long job){Company c=companyService.findCompanyByOwnerUserId(user);if(c==null)throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);jobService.assertOwnerOrThrow(c.getId(),job);}
private void assertCompanyOwns(Long user, Long job) {
Company company = companyService.findCompanyByOwnerUserId(user);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
VerifiedCompanyGuard.requireVerified(company);
jobService.assertOwnerOrThrow(company.getId(), job);
}
private void validatePage(int p,int s){if(p<0||s<1||s>50)throw new BusinessException(ErrorCode.RECOMMENDATION_INVALID_QUERY);}
private RecommendationProfileResponse profileView(JobRecommendationProfile p){return RecommendationProfileResponse.builder().jobId(p.getJobId()).industryCode(p.getIndustryCode()).jobFamilyCode(p.getJobFamilyCode()).riasecWeights(mapInteger(p.getRiasecWeightsJson())).abilityExpectations(mapInteger(p.getAbilityExpectationsJson())).enabled(p.getEnabled()).reviewStatus(p.getReviewStatus()).reviewReason(p.getReviewReason()).version(p.getVersion()).updatedAt(p.getUpdatedAt()).build();}
private Map<String,Integer> mapInteger(String raw){try{return raw==null?Map.of():JSON.readValue(raw,new TypeReference<Map<String,Integer>>(){});}catch(Exception e){return Map.of();}}

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

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

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

Loading…
Cancel
Save