Compare commits

...

22 Commits

Author SHA1 Message Date
Zhang Shun d5889ba98c fix(resume): protect legacy writes with revision and classify internships 6 days ago
Zhang Shun 2770d4c544 test(career-progress): 补充分页和可选摘要契约回归 7 days ago
Zhang Shun 216267c628 feat: 完善在线简历资料保存与导出接口 1 week ago
Zhang Shun 67b095724b fix: allow reapplication after withdrawal with isolated history and authorization 1 week ago
Zhang Shun 41ab7ba9d4 feat(jobs): add industry experience and education filters 1 week ago
Zhang Shun 91cbf6cd4d feat: complete job display fields and harden resume access 1 week ago
Zhang Shun 1cbadc3e18 feat(messaging): 完善简历快照消息与投递流程约束 2 weeks ago
Zhang Shun 5841e09190 feat(application): 补齐求职者投递列表岗位摘要并验证批量查询 2 weeks ago
Zhang Shun 554c6eaef4 fix(chat): 修复 MySQL 5.7 会话查询并补齐验收契约 2 weeks ago
Zhang Shun 6fff2a2217 feat(chat): 增加实时通信并统一轻松聘品牌 2 weeks ago
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. 6
      README.md
  2. 2
      backend-infra/README.md
  3. 18
      backend-infra/nginx-chat-websocket.location.conf.example
  4. 77
      changes/n03-skill-cloud/data/source-manifest.md
  5. 2
      docs/ERD.md
  6. 103
      docs/_update_job_company_display_fields_openapi.py
  7. 150
      docs/acceptance/application-reapplication-20261020/blackbox.py
  8. 25
      docs/acceptance/application-reapplication-20261020/verify_contract.py
  9. 354
      docs/acceptance/batch-display-fields-20261016/b01_blackbox.py
  10. 140
      docs/acceptance/batch-display-fields-20261016/b01_contract_negative_tests.py
  11. 249
      docs/acceptance/batch-display-fields-20261016/b01_openapi_compare.py
  12. 67
      docs/application-reapplication-handover-20261020.md
  13. 5
      docs/backend-development-roadmap.md
  14. 266
      docs/batch-job-company-display-fields-frontend-handover.md
  15. 115
      docs/batch-job-company-display-fields-report.md
  16. 68
      docs/chat-realtime-api.md
  17. 73
      docs/first-password-setup-implementation.md
  18. 17
      docs/frontend-integration.md
  19. 2
      docs/job-review-api.md
  20. 2
      docs/match-api.md
  21. 2
      docs/offer-advance-api.md
  22. 2
      docs/offer-api.md
  23. 434
      docs/onboarding-api.md
  24. 104
      docs/online-resume-frontend-handover-20261020.md
  25. 36
      docs/online-resume-r01e-query-budget-20261020.md
  26. 13577
      docs/openapi-rc.json
  27. 163
      docs/phase-b-company-verification-gate-implementation-report.md
  28. 136
      docs/phase-b-company-verification-gate-independent-verification-20260923.md
  29. 2
      docs/resume-api.md
  30. 129
      docs/resume-ownership-hardening-20261016.md
  31. 2
      docs/review-moderation-api.md
  32. 198
      docs/salary-calculator-api.md
  33. 275
      docs/skill-cloud-api.md
  34. 79
      docs/skill-taxonomy-sources.md
  35. 866
      docs/tools/b01_demo_display_data.py
  36. 594
      docs/tools/b01_demo_display_data_test.py
  37. 49
      docs/unified-registration-identity-selection-design.md
  38. 159
      docs/unified-registration-identity-selection-implementation-report.md
  39. 214
      docs/verify_openapi.py
  40. 4
      mydreamjob-server/README.md
  41. 12
      mydreamjob-server/pom.xml
  42. 18
      mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java
  43. 10
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java
  44. 12
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java
  45. 68
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java
  46. 4
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatus.java
  47. 16
      mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java
  48. 4
      mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java
  49. 2
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java
  50. 2
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java
  51. 15
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java
  52. 2
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java
  53. 5
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java
  54. 23
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsFailedAttemptRecorder.java
  55. 6
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java
  56. 12
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java
  57. 1
      mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java
  58. 6
      mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java
  59. 4
      mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java
  60. 11
      mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupRequest.java
  61. 6
      mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupResponse.java
  62. 76
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java
  63. 31
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionRequest.java
  64. 8
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionResponse.java
  65. 104
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionService.java
  66. 5
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java
  67. 18
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallenge.java
  68. 16
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallengeRepository.java
  69. 73
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupService.java
  70. 57
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/AliyunSmsCodeSender.java
  71. 35
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/AliyunSmsProperties.java
  72. 3
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java
  73. 26
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java
  74. 2
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java
  75. 10
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java
  76. 18
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java
  77. 9
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java
  78. 3
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java
  79. 2
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java
  80. 4
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java
  81. 10
      mydreamjob-server/src/main/java/com/mydreamjob/common/ProductBrand.java
  82. 21
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  83. 17
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java
  84. 15
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
  85. 3
      mydreamjob-server/src/main/java/com/mydreamjob/company/Company.java
  86. 24
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java
  87. 44
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java
  88. 41
      mydreamjob-server/src/main/java/com/mydreamjob/company/VerifiedCompanyGuard.java
  89. 8
      mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyCreateRequest.java
  90. 19
      mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyPublicView.java
  91. 7
      mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyUpdateRequest.java
  92. 90
      mydreamjob-server/src/main/java/com/mydreamjob/config/MySql57MigrationConfiguration.java
  93. 2
      mydreamjob-server/src/main/java/com/mydreamjob/config/OpenApiSecurityConfig.java
  94. 19
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  95. 3
      mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java
  96. 67
      mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
  97. 4
      mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java
  98. 7
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java
  99. 28
      mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java
  100. 39
      mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewService.java

6
README.md

@ -1,6 +1,6 @@
# 职来(mydreamjob) # 轻松聘(mydreamjob)
职来是一个面向求职者、企业和平台运营方的 AI 求职招聘平台。本仓库是职来项目的后端与工程协作仓库,不包含“青核校园”React Native 项目。 轻松聘是一个面向求职者、企业和平台运营方的 AI 求职招聘平台。本仓库是轻松聘项目的后端与工程协作仓库,不包含“青核校园”React Native 项目。
## 当前交付基线 ## 当前交付基线
@ -175,4 +175,4 @@ python .\docs\verify_openapi.py
`C:\Users\15642\Desktop\青核\collegeCli` `C:\Users\15642\Desktop\青核\collegeCli`
青核项目不属于本仓库,禁止将其源代码提交到职来仓库。 青核项目不属于本仓库,禁止将其源代码提交到轻松聘仓库。

2
backend-infra/README.md

@ -1,4 +1,4 @@
# 职来本地后端依赖 # 轻松聘本地后端依赖
此目录仅用于本地开发的 MySQL、Redis 和 RabbitMQ,不会修改电脑中已运行的 MySQL 8.0 服务。 此目录仅用于本地开发的 MySQL、Redis 和 RabbitMQ,不会修改电脑中已运行的 MySQL 8.0 服务。

18
backend-infra/nginx-chat-websocket.location.conf.example

@ -0,0 +1,18 @@
# 实时聊天转发片段:合并到现有 HTTPS server 中,不要覆盖整个 Nginx 配置。
# 部署前核对真实后端地址、端口以及 /api/v1 context-path。
# 此示例仅用于单后端实例;多实例需要共享消息代理,不能仅添加负载均衡。
# 前端使用 wss://你的业务域名/api/v1/ws;Token 放在 STOMP CONNECT 头,不放 URL。
location = /api/v1/ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 75s;
proxy_send_timeout 75s;
proxy_buffering off;
}
# 握手限流目前按直接连接来源计数;经过反向代理时来源可能都是网关。
# 大规模部署需在网关增加按可信真实 IP 的限流,并核对后端限流配置。

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

2
docs/ERD.md

@ -1,4 +1,4 @@
# 职来 (mydreamjob) 数据库 ERD 设计 # 轻松聘 (mydreamjob) 数据库 ERD 设计
> 版本: v0.1 | 更新日期: 2026-08-03 | 状态: Week 1 基线 > 版本: v0.1 | 更新日期: 2026-08-03 | 状态: Week 1 基线

103
docs/_update_job_company_display_fields_openapi.py

@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
"""岗位与企业展示字段批次:把运行时 OpenAPI 的展示字段约束与文档模型同步进静态契约
``docs/openapi-rc.json``。
本轮整改(对照整改项 2)后,本批相关字段的约束由**纯文档型注解**在运行时表达
(如 ``@Schema(types={"string","null"}, maxLength=40)``、``@ArraySchema(maxItems=20, schema=@Schema(types={"string"}, maxLength=40))``),
因此静态契约与运行时契约可以在**同一 OpenAPI 3.1 表示**下逐项对齐:
* ``JobCreateRequest`` / ``JobUpdateRequest``:experience/education(≤40,可空)、
workLocation(≤255,可空)、benefits(数组、最多 20 项、单项 ≤40、元素为 string)、
salaryMonths(整数 1—24,可空),以及 salaryMin/salaryMax 的“元/月”说明。
* ``CompanyCreateRequest`` / ``CompanyUpdateRequest`` / ``CompanyPublicView`` 的 companySize(≤40,可空)。
* ``CompanyManagedJobItem`` 的展示字段。
* Map 型岗位响应的**文档模型**:``ApiResponseJobDetailViewDoc`` / ``ApiResponseJobListViewDoc`` /
``ApiResponseJobPublicViewDoc`` 及其 data(``JobDetailViewDoc``/``JobListViewDoc``/``JobPublicViewDoc``/``JobDocCompanySummary``)。
这 7 个组件整体从运行时同步,并把 ``GET /jobs``、``POST /jobs``、``PUT /jobs/{jobId}``、``GET /jobs/{jobId}``、
``GET /jobs/public/{jobId}`` 的 200 响应引用与说明对齐。
不新增/删除端点(本批基线 paths=201 / operations=245 不变)。
用法:
python docs/_update_job_company_display_fields_openapi.py [--runtime <runtime-openapi.json>]
"""
import argparse
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent
STATIC = ROOT / "openapi-rc.json"
DEFAULT_RUNTIME = ROOT / "acceptance" / "batch-display-fields-20261016" / "runtime-openapi.json"
DISPLAY_FIELDS = ("experience", "education", "benefits", "salaryMonths", "workLocation")
SALARY_FIELDS = ("salaryMin", "salaryMax")
JOB_REQUEST_SCHEMAS = ("JobCreateRequest", "JobUpdateRequest")
COMPANY_SIZE_SCHEMAS = ("CompanyCreateRequest", "CompanyUpdateRequest", "CompanyPublicView")
DOC_SCHEMAS = ("JobDocCompanySummary", "JobDetailViewDoc", "JobPublicViewDoc", "JobListViewDoc",
"ApiResponseJobDetailViewDoc", "ApiResponseJobPublicViewDoc", "ApiResponseJobListViewDoc")
MAP_RESPONSE_OPS = (("/jobs", "get", "ApiResponseJobListViewDoc"),
("/jobs", "post", "ApiResponseJobDetailViewDoc"),
("/jobs/{jobId}", "get", "ApiResponseJobDetailViewDoc"),
("/jobs/{jobId}", "put", "ApiResponseJobDetailViewDoc"),
("/jobs/public/{jobId}", "get", "ApiResponseJobPublicViewDoc"))
def patch_display_props(static_schemas, runtime_schemas):
for name in JOB_REQUEST_SCHEMAS:
sp = static_schemas[name]["properties"]
rp = runtime_schemas[name]["properties"]
benefits = rp['benefits']
assert benefits.get('x-normalization') == dict(trimItems=True, deduplicate=True, rejectBlankItems=True,
maxItems=20, itemMaxLength=40), '运行时福利语义错误,拒绝覆盖静态契约'
assert 'maxItems' not in benefits and 'maxLength' not in benefits.get('items', {}), '拒绝写入原始请求限制'
for field in DISPLAY_FIELDS + SALARY_FIELDS:
sp[field] = rp[field]
for name in COMPANY_SIZE_SCHEMAS:
static_schemas[name]["properties"]["companySize"] = \
runtime_schemas[name]["properties"]["companySize"]
mp = static_schemas["CompanyManagedJobItem"]["properties"]
rmp = runtime_schemas["CompanyManagedJobItem"]["properties"]
for field in DISPLAY_FIELDS + SALARY_FIELDS:
mp[field] = rmp[field]
def patch_doc_schemas(static_schemas, runtime_schemas):
for name in DOC_SCHEMAS:
assert name in runtime_schemas, "运行时缺少文档模型 %s" % name
static_schemas[name] = runtime_schemas[name]
def patch_response_refs(static_paths, runtime_paths):
for path, method, schema in MAP_RESPONSE_OPS:
static_resp = static_paths[path][method]["responses"]["200"]
runtime_resp = runtime_paths[path][method]["responses"]["200"]
static_resp["description"] = runtime_resp.get("description", static_resp.get("description", "OK"))
static_resp["content"] = runtime_resp["content"]
ref = runtime_resp["content"]["*/*"]["schema"].get("$ref", "")
assert ref.endswith(schema), "%s %s 运行时响应引用异常:%s" % (method.upper(), path, ref)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--runtime", default=str(DEFAULT_RUNTIME))
args = parser.parse_args()
doc = json.load(open(STATIC, encoding="utf-8"))
runtime = json.load(open(args.runtime, encoding="utf-8"))
static_schemas = doc["components"]["schemas"]
runtime_schemas = runtime["components"]["schemas"]
patch_display_props(static_schemas, runtime_schemas)
patch_doc_schemas(static_schemas, runtime_schemas)
patch_response_refs(doc["paths"], runtime["paths"])
with open(STATIC, "w", encoding="utf-8", newline="\n") as fh:
json.dump(doc, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("display-fields OpenAPI updated: paths=", len(doc["paths"]),
"operations=", sum(len(v) for v in doc["paths"].values()),
"schemas=", len(static_schemas))
if __name__ == "__main__":
main()

150
docs/acceptance/application-reapplication-20261020/blackbox.py

@ -0,0 +1,150 @@
"""重投 JAR 黑盒,仅固定本地测试容器/库。无生产目标参数,无真实短信。"""
import argparse,concurrent.futures,datetime,hashlib,json,os,pathlib,socket,subprocess,time,uuid
import requests,websocket
ROOT=pathlib.Path('D:/Workspaces/qsp-reapply-20261020')
API='http://127.0.0.1:18090/api/v1'
DB='qsp_reapply_1020'
PASSWORD='qsp-reapply-isolated-only'
NAMES=['qsp-reapply-1020-mysql','qsp-reapply-1020-redis','qsp-reapply-1020-rabbit']
RESULTS=[]
def save(name,data):
(ROOT/name).write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding='utf-8')
def inspect(name):return json.loads(subprocess.check_output(['docker','inspect',name]))[0]
def sql(text):
i=inspect(NAMES[0]);assert i['Name']=='/'+NAMES[0] and i['HostConfig']['PortBindings']['3306/tcp'][0]['HostIp']=='127.0.0.1'
p=subprocess.run(['docker','exec','-i',NAMES[0],'mysql','-uroot','-p'+PASSWORD,'--default-character-set=utf8mb4','-NB',DB],input=text,capture_output=True,text=True,encoding='utf-8')
if p.returncode:raise RuntimeError(p.stderr[:400])
return [line.split('\t') for line in p.stdout.strip().splitlines() if line]
def check(name,ok,detail=None):
RESULTS.append({'case':name,'ok':bool(ok),'detail':detail});save('blackbox-results.json',RESULTS);print(name,bool(ok),flush=True)
if not ok:raise AssertionError(name)
def http(method,path,token=None,body=None,files=None):
return requests.request(method,API+path,headers={'Authorization':'Bearer '+token} if token else {},json=body,files=files,timeout=30)
def good(name,method,path,token=None,body=None):
r=http(method,path,token,body);j=r.json();check(name,r.status_code==200,{'status':r.status_code,'response':j});return j['data']
def denied(name,method,path,token,body,code):
expected={7001:404,8001:409,8303:403,8304:409,8309:409,8106:400,2002:403}[code]
r=http(method,path,token,body);j=r.json();check(name,r.status_code==expected and j.get('code')==code,{'expectedStatus':expected,'status':r.status_code,'response':j})
def login(phone):
sql("INSERT INTO sms_verification_codes(phone,code,purpose,attempts,max_attempts,expired_at,used,created_at) VALUES('%s','246810','login',0,5,DATE_ADD(NOW(),INTERVAL 1 DAY),0,NOW())"%phone)
r=http('POST','/auth/login',body={'phone':phone,'code':'246810','purpose':'login'})
if r.status_code!=200:raise RuntimeError('Isolated login failed')
return r.json()['data']['accessToken']
def setup():
for port in [3309,16390,15690,18090,18091]:
with socket.socket() as s:
if s.connect_ex(('127.0.0.1',port))==0:raise RuntimeError('Occupied port '+str(port))
for name in NAMES:
if subprocess.run(['docker','inspect',name],capture_output=True).returncode==0:raise RuntimeError('Existing container: '+name)
state={'containers':{},'volumes':[]}
save('environment.json',state)
for name,image,port,internal,memory,extra in [
(NAMES[0],'mysql:8.4',3309,3306,'768m',['-e','MYSQL_ROOT_PASSWORD='+PASSWORD,'-e','MYSQL_DATABASE='+DB]),
(NAMES[1],'redis:7.4-alpine',16390,6379,'128m',[]),
(NAMES[2],'rabbitmq:3.13-alpine',15690,5672,'256m',[])]:
cmd=['docker','run','-d','--name',name,'--label','qsp.acceptance=reapplication-20261020','--memory='+memory,'-p',f'127.0.0.1:{port}:{internal}']+extra+[image]
cid=subprocess.check_output(cmd,text=True).strip();state['containers'][name]=cid
state['volumes'].extend(m['Name'] for m in inspect(name)['Mounts'] if m['Type']=='volume');save('environment.json',state)
for _ in range(60):
try:sql('SELECT 1');return
except Exception:time.sleep(1)
raise RuntimeError('MySQL readiness timeout')
def start():
jar=ROOT/'mydreamjob-server/target/mydreamjob-server-0.0.1-SNAPSHOT.jar'
for name in ['logs','tmp','storage','resume-exports','data-exports']:(ROOT/name).mkdir(exist_ok=True)
env=os.environ.copy();env.update(DB_URL=f'jdbc:mysql://127.0.0.1:3309/{DB}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai',DB_USERNAME='root',DB_PASSWORD=PASSWORD,
REDIS_HOST='127.0.0.1',REDIS_PORT='16390',REDIS_PASSWORD='',RABBITMQ_HOST='127.0.0.1',RABBITMQ_PORT='15690',RABBITMQ_USERNAME='guest',RABBITMQ_PASSWORD='guest',
JWT_ACCESS_SECRET='reapply-isolated-access-secret-no-production-0001',JWT_REFRESH_SECRET='reapply-isolated-refresh-secret-no-production-001',JWT_TOKEN_HASH_SECRET='reapply-isolated-tokenhash-secret-no-production-01',
SMS_ALIYUN_ENABLED='false',SMS_MOCK_ENABLED='true',ADMIN_BOOTSTRAP_ENABLED='false',RESUME_STORAGE_DIR=str(ROOT/'storage'),RESUME_EXPORT_DIR=str(ROOT/'resume-exports'),DATA_EXPORT_DIR=str(ROOT/'data-exports'),TEMP=str(ROOT/'tmp'),TMP=str(ROOT/'tmp'))
cmd=['java','-Xms128m','-Xmx512m','-Djava.io.tmpdir='+str(ROOT/'tmp'),'-jar',str(jar),'--server.address=127.0.0.1','--server.port=18090','--management.server.port=18091',
'--app.offer.scheduler.enabled=false','--app.match.notification.enabled=false','--app.notification.mq-enabled=false','--app.risk.scan.enabled=false','--app.scoring.refresh.enabled=false','--app.scoring.refresh.compensation-enabled=false','--spring.rabbitmq.listener.simple.auto-startup=false']
p=subprocess.Popen(cmd,env=env,cwd=ROOT,stdout=open(ROOT/'logs/blackbox-app.log','ab'),stderr=open(ROOT/'logs/blackbox-app.err.log','ab'),creationflags=subprocess.CREATE_NO_WINDOW)
save('process.json',{'pid':p.pid,'jar':str(jar),'sha256':hashlib.sha256(jar.read_bytes()).hexdigest()})
for _ in range(60):
if p.poll() is not None:raise RuntimeError('App exited')
try:
if requests.get('http://127.0.0.1:18091/actuator/health',timeout=2).status_code==200:print('APP_READY',p.pid);return
except requests.RequestException:pass
time.sleep(1)
raise RuntimeError('App health timeout')
def run():
stamp=uuid.uuid4().hex[:8];suffix=uuid.uuid4().int%999999
phones=['19908'+str(suffix+i).zfill(6) for i in range(2)]
for phone,role in zip(phones,['STUDENT','COMPANY']):
sql("INSERT INTO users(phone,nickname,role,is_active,is_verified) VALUES('%s','synthetic-%s','%s',1,1)"%(phone,stamp,role))
sql("INSERT INTO user_roles(user_id,role) SELECT id,'%s' FROM users WHERE phone='%s'"%(role,phone))
student=int(sql("SELECT id FROM users WHERE phone='%s'"%phones[0])[0][0]);hr=int(sql("SELECT id FROM users WHERE phone='%s'"%phones[1])[0][0])
sql("INSERT INTO companies(owner_user_id,name,verified) VALUES(%d,'synthetic-reapply',1)"%hr)
ta,th=map(login,phones)
pdf=pathlib.Path('D:/Acceptance/qsp-filter-full-20261019-1/continuation-1/synthetic-resume.pdf').read_bytes()
r=http('POST','/profile/me/resume',ta,files={'file':('synthetic.pdf',pdf,'application/pdf')});check('real PDF upload',r.status_code==200)
good('privacy','PUT','/profile/me/privacy',ta,{'resumeVisibility':'APPLIED_COMPANIES','contactVisible':False})
job=good('create job','POST','/jobs',th,{'title':'synthetic-reapply','description':'not real recruitment','city':'TEST','employmentType':'FULL_TIME'})['id']
good('publish job','PATCH',f'/jobs/{job}/status',th,{'status':'PUBLISHED'})
def apply():return http('POST','/applications',ta,{'jobId':job})
def match(app):
good('candidate scores new round','PUT',f'/applications/{app}/candidate-interest-score',ta,{'score':90})
d=good('HR scores new round','PUT',f'/applications/{app}/hr-suitability-score',th,{'score':90});check('new round matched',d['status']=='MATCHED')
return good('matched conversation','POST',f'/applications/{app}/conversation',ta)['id']
a=apply().json()['data'];check('first round',a['attemptNumber']==1)
conv=match(a['id']);oldmsg=good('old resume card','POST',f'/conversations/{conv}/resume-messages',ta,{'applicationId':a['id'],'clientMessageId':'old-'+stamp})['id']
future=datetime.datetime.now()+datetime.timedelta(days=2)
interview=good('old interview fixture','POST',f"/applications/{a['id']}/interviews",th,{'title':'synthetic old interview','interviewType':'ONLINE','startAt':future.isoformat(),'endAt':(future+datetime.timedelta(hours=1)).isoformat(),'meetingUrl':'https://example.invalid/interview'})
offer=good('old draft offer fixture','POST','/offers',th,{'applicationId':a['id'],'title':'synthetic old offer','description':'not real employment','workCity':'TEST','expireAt':future.isoformat()})
oldurl=f'/conversations/{conv}/messages/{oldmsg}/resume'
good('withdraw first round','POST',f"/applications/{a['id']}/withdraw",ta)
denied('old card denied after withdrawal','GET',oldurl,th,None,8309)
denied('old company download denied','GET',f"/company/candidates/{a['id']}/resume",th,None,2002)
denied('old application cannot unlock','POST',f"/applications/{a['id']}/conversation",ta,None,8304)
denied('historical send locked','POST','/conversations/messages',ta,{'conversationId':conv,'content':'blocked'},8303)
good('history readable','GET',f'/conversations/{conv}/messages',ta)
b=apply().json()['data'];check('second id and lineage',b['id']!=a['id'] and b['attemptNumber']==2 and b['previousApplicationId']==a['id'],b)
d=good('new round interview list','GET',f"/applications/{b['id']}/interviews",th);check('interviews not copied',d==[],d)
d=good('new round offer list','GET',f"/offers?applicationId={b['id']}",th);check('offers not copied',d['total']==0,d)
denied('old offer cannot be sent after withdrawal','PATCH',f"/offers/{offer['id']}/status",th,{'status':'SENT'},8106)
denied('new round not auto-matched','POST',f"/applications/{b['id']}/conversation",ta,None,8304)
denied('new round cannot send inherited card','POST',f'/conversations/{conv}/resume-messages',ta,{'applicationId':b['id'],'clientMessageId':'pending-'+stamp},8303)
good('withdraw second round','POST',f"/applications/{b['id']}/withdraw",ta)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:responses=list(pool.map(lambda _:apply(),range(4)))
wins=[r for r in responses if r.status_code==200];losers=[r for r in responses if r.status_code!=200]
check('concurrent one winner three business duplicates',len(wins)==1 and len(losers)==3 and all(r.json().get('code')==8001 for r in losers),[{'status':r.status_code,'data':r.json()} for r in responses])
c=wins[0].json()['data'];check('third round lineage',c['attemptNumber']==3 and c['previousApplicationId']==b['id'],c)
newpdf=pdf+b'\n% new-round-current-resume\n';r=http('POST','/profile/me/resume',ta,files={'file':('current.pdf',newpdf,'application/pdf')});check('replace source before rematch',r.status_code==200)
check('rematch reuses historical chat',match(c['id'])==conv)
ws=websocket.create_connection('ws://127.0.0.1:18090/api/v1/ws',origin='http://localhost:3000',subprotocols=['v12.stomp'],timeout=10,http_proxy_host=None)
ws.send('CONNECT\naccept-version:1.2\nheart-beat:0,0\nAuthorization:Bearer '+th+'\n\n\x00');check('STOMP after rematch authenticated',ws.recv().startswith('CONNECTED'))
ws.send('SUBSCRIBE\nid:reapply\ndestination:/user/queue/chat-events\n\n\x00');check('STOMP subscription ready','CHAT_READY' in ws.recv())
text=good('send after fresh match','POST','/conversations/messages',ta,{'conversationId':conv,'content':'new round','clientMessageId':'new-'+stamp})
frame=ws.recv();event=json.loads(frame.split('\n\n',1)[1].rstrip('\x00'));check('new round websocket exact message',event.get('type')=='MESSAGE_CREATED' and event.get('payload',{}).get('id')==text['id'],event);ws.close()
denied('old card remains denied despite new match','GET',oldurl,th,None,8309)
newmsg=good('new round resume card','POST',f'/conversations/{conv}/resume-messages',ta,{'applicationId':c['id'],'clientMessageId':'resume-'+stamp})['id']
newurl=f'/conversations/{conv}/messages/{newmsg}/resume';r=http('GET',newurl,th);check('new card uses current PDF',r.status_code==200 and r.content==newpdf)
timeline=good('old timeline retained','GET',f"/applications/{a['id']}/timeline",ta);check('old withdrawal in timeline',any(x['toStatus']=='WITHDRAWN' for x in timeline))
page=good('student list','GET','/applications/me',ta);items=[x for x in page['items'] if x['jobId']==job];check('three rounds listed',len(items)==3 and {x['attemptNumber'] for x in items}=={1,2,3})
d=good('company candidate detail','GET',f"/company/candidates/{c['id']}",th);check('company detail lineage',d['attemptNumber']==3 and d['previousApplicationId']==b['id'],d)
d=good('company candidate list','GET',f'/company/candidates?jobId={job}',th);check('company list preserves all rounds',len(d['items'])==3 and {x['attemptNumber'] for x in d['items']}=={1,2,3},d)
closed=good('closed fixture job','POST','/jobs',th,{'title':'closed fixture','description':'synthetic','city':'TEST','employmentType':'FULL_TIME'})['id']
good('publish closed fixture','PATCH',f'/jobs/{closed}/status',th,{'status':'PUBLISHED'})
app=good('apply closed fixture','POST','/applications',ta,{'jobId':closed})['id'];good('withdraw closed fixture','POST',f'/applications/{app}/withdraw',ta)
good('close fixture job','PATCH',f'/jobs/{closed}/status',th,{'status':'CLOSED'})
denied('closed job blocks reapply','POST','/applications',ta,{'jobId':closed},7001)
rejected=good('rejected fixture job','POST','/jobs',th,{'title':'rejected fixture','description':'synthetic','city':'TEST','employmentType':'FULL_TIME'})['id']
good('publish rejected fixture','PATCH',f'/jobs/{rejected}/status',th,{'status':'PUBLISHED'})
app=good('apply rejected fixture','POST','/applications',ta,{'jobId':rejected})['id'];good('reject fixture application','PATCH',f'/applications/{app}/status',th,{'status':'REJECTED'})
denied('rejected still blocks reapply','POST','/applications',ta,{'jobId':rejected},8001)
save('blackbox-state.json',{'phones':phones,'job':job,'oldApplication':a['id'],'currentApplication':c['id'],'conversation':conv,'oldUrl':oldurl,'newUrl':newurl,'sha256':hashlib.sha256(newpdf).hexdigest()})
def recheck():
s=json.loads((ROOT/'blackbox-state.json').read_text(encoding='utf-8'));ta,th=map(login,s['phones'])
d=good('current application survives restart','GET',f"/applications/{s['currentApplication']}",ta);check('third round persists',d['attemptNumber']==3 and d['status']=='SUBMITTED')
d=good('old application survives restart','GET',f"/applications/{s['oldApplication']}",ta);check('withdrawn state persists',d['status']=='WITHDRAWN')
denied('old card still denied after restart','GET',s['oldUrl'],th,None,8309)
r=http('GET',s['newUrl'],th);check('new authorized bytes persist',r.status_code==200 and hashlib.sha256(r.content).hexdigest()==s['sha256'])
good('chat sending still unlocked by new match','POST','/conversations/messages',ta,{'conversationId':s['conversation'],'content':'restart','clientMessageId':'restart-'+uuid.uuid4().hex})
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('mode',choices=['setup','start','run','recheck']);a=parser.parse_args()
ROOT.mkdir(parents=True,exist_ok=True)
if a.mode in ['run','recheck']:
try:globals()[a.mode]()
finally:save('blackbox-'+a.mode+'-results.json',RESULTS)
else:globals()[a.mode]()

25
docs/acceptance/application-reapplication-20261020/verify_contract.py

@ -0,0 +1,25 @@
"""本批静态校验及当前隔离 JAR 的契约对照,不修改契约。"""
import json,pathlib,subprocess,sys,requests
ROOT=pathlib.Path('D:/Workspaces/qsp-reapply-20261020')
REPO=pathlib.Path(__file__).resolve().parents[3]
rows=[]
def check(name,ok,actual=None):rows.append({'case':name,'ok':bool(ok),'actual':actual})
p=subprocess.run([sys.executable,str(REPO/'docs/verify_openapi.py')],capture_output=True,text=True,encoding='utf-8',errors='replace',env={**__import__('os').environ,'PYTHONIOENCODING':'utf-8'})
(ROOT/'logs/static-openapi.log').write_text(p.stdout+p.stderr,encoding='utf-8');check('static verifier',p.returncode==0,p.returncode)
static=json.loads((REPO/'docs/openapi-rc.json').read_text(encoding='utf-8'))
r=requests.get('http://127.0.0.1:18090/api/v1/v3/api-docs',timeout=30);check('runtime OpenAPI reachable',r.status_code==200,r.status_code);runtime=r.json()
(ROOT/'runtime-openapi.json').write_text(json.dumps(runtime,ensure_ascii=False),encoding='utf-8')
check('same path identities',set(static['paths'])==set(runtime['paths']))
methods={'get','post','put','patch','delete','head','options','trace'}
ops=lambda doc:{(p,m) for p,vs in doc['paths'].items() for m in vs if m in methods}
check('same operation identities',ops(static)==ops(runtime))
for name in ['CandidateItemView','CandidateDetailView']:
for field,fmt in [('attemptNumber','int32'),('previousApplicationId','int64')]:
a=static['components']['schemas'][name]['properties'][field];b=runtime['components']['schemas'][name]['properties'][field]
check(name+'.'+field,a.get('type')==b.get('type')=='integer' and a.get('format')==b.get('format')==fmt,{'static':a,'runtime':b})
forbidden=set(runtime['components']['schemas'][name]['properties'])&{'studentUserId','phone','email','resumeUrl','storagePath'}
check(name+' remains redacted',not forbidden,sorted(forbidden))
for path in ['/applications','/applications/{id}/withdraw']:
check(path+' description',static['paths'][path]['post']['description']==runtime['paths'][path]['post']['description'])
(ROOT/'contract-results.json').write_text(json.dumps(rows,ensure_ascii=False,indent=2),encoding='utf-8')
print('CONTRACT',len(rows),'FAIL',sum(not r['ok'] for r in rows));raise SystemExit(any(not r['ok'] for r in rows))

354
docs/acceptance/batch-display-fields-20261016/b01_blackbox.py

@ -0,0 +1,354 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""岗位与企业展示字段批次:HTTP 黑盒验收(针对本批隔离 JAR)。
仅访问隔离环境(默认 http://127.0.0.1:18080/api/v1),写操作只发生在 b01-app-mysql 隔离库。
不改认证/安全策略;不打印 Token 原文。
"""
import argparse
import json
import subprocess
import sys
import time
import urllib.error
import urllib.request
CONTAINER = "b01-app-mysql"
DB = "mydreamjob_b01_demo"
DB_USER = "b01demo"
DB_PASS = "b01-demo-only"
PHONE_PREFIX = "19902"
CODE = "246810"
API = "/api/v1"
rows = []
def record(section, case, expected, actual, ok, note=""):
rows.append({"section": section, "case": case, "expected": expected,
"actual": str(actual)[:300], "ok": bool(ok), "note": note})
print("[%s] %-8s %-52s exp=%-26s act=%s %s" % (
"PASS" if ok else "FAIL", section, case, expected, str(actual)[:80], note))
return ok
def http(base, method, path, token=None, body=None, raw=None, timeout=30):
headers = {}
data = None
if raw is not None:
data = raw
headers["Content-Type"] = "application/json"
elif body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = "Bearer " + token
req = urllib.request.Request(base + path, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
except Exception as e:
return -1, "CONNECTION_ERROR: %s" % type(e).__name__
def mysql(sql, db=DB):
p = subprocess.run(["docker", "exec", "-i", CONTAINER, "mysql",
"-u" + DB_USER, "-p" + DB_PASS, "--default-character-set=utf8mb4",
"-N", "-B", db, "-e", sql],
capture_output=True, text=True, encoding="utf-8", errors="replace")
if p.returncode != 0:
raise RuntimeError("mysql failed: %s" % (p.stderr or "").strip()[:300])
return [ln.split("\t") for ln in p.stdout.strip().splitlines() if ln != ""]
def scalar(sql):
r = mysql(sql)
return r[0][0] if r else None
def phone(n):
return "%s%06d" % (PHONE_PREFIX, n)
def seed_sms(p):
mysql("INSERT INTO sms_verification_codes (phone, code, purpose, attempts, max_attempts, "
"expired_at, used, created_at) VALUES ('%s','%s','login',0,5,"
"DATE_ADD(NOW(), INTERVAL 1 DAY),0,NOW());" % (p, CODE))
def login(base, p):
seed_sms(p)
st, body = http(base, "POST", API + "/auth/login", body={"phone": p, "code": CODE, "purpose": "login"})
if st != 200:
raise RuntimeError("login failed for %s: %s %s" % (p, st, body[:200]))
return json.loads(body)["data"]
def as_company(base, p):
data = login(base, p)
uid = scalar("SELECT id FROM users WHERE phone='%s'" % p)
mysql("UPDATE users SET role='COMPANY' WHERE id=%s; INSERT IGNORE INTO user_roles(user_id, role) "
"VALUES (%s,'COMPANY');" % (uid, uid))
return data["accessToken"], int(uid)
def jbody(body):
try:
return json.loads(body)
except Exception:
return {}
def data_of(body):
return jbody(body).get("data")
def run(base, api_prefix):
api = api_prefix
seq = int(time.time()) % 100000
# ── A. 企业资料 + companySize 归一化 ─────────────────────────
tok_a, uid_a = as_company(base, phone(seq))
st, body = http(base, "POST", api + "/companies/me", token=tok_a,
body={"name": "黑盒企业A", "companySize": " 100-499人 ",
"logoUrl": "https://logo.example/b01.png",
"description": "黑盒企业简介", "industry": "互联网",
"website": "https://b01.example"})
d = data_of(body) or {}
record("A", "创建 companySize 去空白", "200/100-499人", "%s/%s" % (st, d.get("companySize")),
st == 200 and d.get("companySize") == "100-499人")
cid = d.get("id")
st, body = http(base, "PUT", api + "/companies/me", token=tok_a, body={"name": "黑盒企业A"})
record("A", "更新缺省保持原值", "100-499人", (data_of(body) or {}).get("companySize"),
(data_of(body) or {}).get("companySize") == "100-499人")
st, body = http(base, "GET", api + "/companies/" + str(cid))
record("A", "公开读取 companySize", "100-499人", (data_of(body) or {}).get("companySize"),
(data_of(body) or {}).get("companySize") == "100-499人")
st, _ = http(base, "PUT", api + "/companies/me", token=tok_a,
body={"name": "黑盒企业A", "companySize": "x" * 41})
record("A", "companySize 超长 400", 400, st, st == 400)
st, body = http(base, "PUT", api + "/companies/me", token=tok_a,
body={"name": "黑盒企业A", "companySize": " "})
record("A", "空白清空为 null", "None", (data_of(body) or {}).get("companySize"),
(data_of(body) or {}).get("companySize") in (None, ""))
mysql("UPDATE companies SET verified=1, company_size='100-499人' WHERE id=%s;" % cid)
# ── B. 岗位全字段 + 各接口一致 ───────────────────────────────
city = "B01CITY" + str(seq)
full = {"title": "黑盒岗位", "description": "岗位描述", "requirements": "岗位要求",
"city": city, "employmentType": "FULL_TIME", "salaryMin": 20000, "salaryMax": 40000,
"experience": "3-5年", "education": "本科", "benefits": ["五险一金", "弹性工作"],
"salaryMonths": 14, "workLocation": "上海市浦东新区"}
st, body = http(base, "POST", api + "/jobs", token=tok_a, body=full)
jid = (data_of(body) or {}).get("id")
record("B", "全字段创建岗位", "200", st, st == 200)
st, _ = http(base, "PATCH", api + "/jobs/%s/status" % jid, token=tok_a, body={"status": "PUBLISHED"})
record("B", "发布岗位", "200", st, st == 200)
def check_fields(label, node):
ok = (node.get("experience") == "3-5年" and node.get("education") == "本科"
and node.get("salaryMonths") == 14 and node.get("workLocation") == "上海市浦东新区"
and node.get("benefits") == ["五险一金", "弹性工作"])
record("B", label, "5 字段一致", "exp=%s ben=%s sm=%s" % (
node.get("experience"), node.get("benefits"), node.get("salaryMonths")), ok)
st, body = http(base, "GET", api + "/jobs?city=" + city)
check_fields("GET /jobs 字段一致", (data_of(body) or {}).get("items", [{}])[0])
st, body = http(base, "GET", api + "/jobs/search?city=" + city + "&companyId=" + str(cid))
check_fields("GET /jobs/search 字段一致", (data_of(body) or {}).get("items", [{}])[0])
st, body = http(base, "GET", api + "/jobs/public/" + str(jid))
check_fields("GET /jobs/public/{id} 字段一致", data_of(body) or {})
st, body = http(base, "GET", api + "/companies/%s/jobs" % cid)
check_fields("GET /companies/{id}/jobs 字段一致", (data_of(body) or {}).get("items", [{}])[0])
st, body = http(base, "GET", api + "/companies/me/jobs", token=tok_a)
check_fields("GET /companies/me/jobs 字段一致", (data_of(body) or {}).get("items", [{}])[0])
st, body = http(base, "GET", api + "/jobs/" + str(jid), token=tok_a)
check_fields("GET /jobs/{id} 字段一致", data_of(body) or {})
st, body = http(base, "PUT", api + "/jobs/" + str(jid), token=tok_a, body={"title": "黑盒岗位-更新"})
check_fields("PUT /jobs/{id} 响应字段一致", data_of(body) or {})
# ── C. 缺省 / null / 空白 / benefits=[] ─────────────────────
st, body = http(base, "POST", api + "/jobs", token=tok_a, body={
"title": "最小岗位", "description": "d", "city": city, "employmentType": "FULL_TIME"})
d2 = data_of(body) or {}
record("C", "缺省新字段读为 null/[]", "all None + []",
"exp=%s sm=%s ben=%s" % (d2.get("experience"), d2.get("salaryMonths"), d2.get("benefits")),
d2.get("experience") is None and d2.get("salaryMonths") is None and d2.get("benefits") == [])
st, body = http(base, "PUT", api + "/jobs/%s" % jid, token=tok_a, body={"benefits": []})
record("C", "benefits=[] 清空", "[]", (data_of(body) or {}).get("benefits"),
(data_of(body) or {}).get("benefits") == [])
st, body = http(base, "PUT", api + "/jobs/%s" % jid, token=tok_a,
body={"experience": " 5-10年 ", "workLocation": " "})
d3 = data_of(body) or {}
record("C", "空白清空 workLocation / trim experience", "5-10年 / None",
"%s / %s" % (d3.get("experience"), d3.get("workLocation")),
d3.get("experience") == "5-10年" and d3.get("workLocation") is None)
# ── D. salaryMonths 类型与范围 ──────────────────────────────
base_job = {"title": "月数岗位", "description": "d", "city": city, "employmentType": "FULL_TIME"}
for raw in ["1", "24"]:
payload = json.dumps({**dict(base_job, title="月数OK" + raw), "salaryMonths": int(raw)}).encode()
st, body = http(base, "POST", api + "/jobs", token=tok_a, raw=payload)
record("D", "salaryMonths=%s 成功" % raw, 200, st, st == 200)
for raw in ["0", "-1", "25", "1.9", "24.9", "1.0", '"12"', '""', "true", "[]", "{}", "99999999999"]:
body = ('{"title":"月数非法","description":"d","city":"%s","employmentType":"FULL_TIME",'
'"salaryMonths":%s}' % (city, raw)).encode()
st, resp = http(base, "POST", api + "/jobs", token=tok_a, raw=body)
record("D", "salaryMonths=%s -> 400" % raw, 400, st, st == 400)
# ── E. 超长/非法 benefits -> 400 且无部分更新 ────────────────
st, _ = http(base, "POST", api + "/jobs", token=tok_a,
body=dict(base_job, title="超长经验", experience="e" * 41))
record("E", "experience 41 字符 -> 400", 400, st, st == 400)
st, _ = http(base, "POST", api + "/jobs", token=tok_a,
body=dict(base_job, title="超多福利", benefits=["b%d" % i for i in range(21)]))
record("E", "benefits 21 项 -> 400", 400, st, st == 400)
before = int(scalar("SELECT count(*) FROM jobs WHERE title='B01PARTIAL'"))
st, _ = http(base, "POST", api + "/jobs", token=tok_a,
body=dict(base_job, title="B01PARTIAL", salaryMonths=25))
after = int(scalar("SELECT count(*) FROM jobs WHERE title='B01PARTIAL'"))
record("E", "失败创建无部分写入", 400, "before=%d after=%d" % (before, after),
st == 400 and after == before)
st, _ = http(base, "PUT", api + "/jobs/" + str(jid), token=tok_a,
body={"title": "B01PARTIAL_TITLE", "salaryMonths": 25})
title_now = scalar("SELECT title FROM jobs WHERE id=%s" % jid)
record("E", "失败更新无部分写入", 400, "%s / %s" % (st, title_now),
st == 400 and title_now == "黑盒岗位-更新")
# ── F. 公开企业摘要白名单 ───────────────────────────────────
st, body = http(base, "GET", api + "/companies/" + str(cid))
node = data_of(body) or {}
keys = sorted(node.keys())
record("F", "公开企业摘要 7 字段", 7, keys,
keys == sorted(["id", "name", "logoUrl", "description", "industry", "verified", "companySize"]))
raw = body
leak_keys = [k for k in ("ownerUserId", "address", "website", "phone", "email",
"materialsJson", "reviewerId", "decisionReason") if k in node]
record("F", "无 ownerUserId/地址/联系方式", "no leak", "裸响应含敏感字段=%s" % leak_keys,
not leak_keys and "ownerUserId" not in raw)
# ── G. 草稿不可公开 / 跨企业不可改 ──────────────────────────
st, body = http(base, "POST", api + "/jobs", token=tok_a,
body=dict(base_job, title="黑盒草稿"))
draft = (data_of(body) or {}).get("id")
st, _ = http(base, "GET", api + "/jobs/public/" + str(draft))
record("G", "草稿公开详情 404", 404, st, st == 404)
tok_b, _ = as_company(base, phone(seq + 1))
st, _ = http(base, "PUT", api + "/jobs/%s" % jid, token=tok_b, body={"title": "越权"})
record("G", "跨企业修改 404", 404, st, st == 404)
# ── H. 历史 NULL 记录读取 ───────────────────────────────────
mysql("INSERT INTO jobs (company_id,title,description,city,employment_type,status,created_at,updated_at) "
"VALUES (%s,'B01LEGACY','desc','%s','FULL_TIME','PUBLISHED',NOW(),NOW());" % (cid, city))
legacy = scalar("SELECT id FROM jobs WHERE title='B01LEGACY'")
st, body = http(base, "GET", api + "/jobs/public/" + str(legacy))
node = data_of(body) or {}
record("H", "历史记录 benefits=[] 且新字段 null", "[] / None",
"%s / %s" % (node.get("benefits"), node.get("experience")),
node.get("benefits") == [] and node.get("experience") is None)
# ── J. 福利限制必须在 trim、去重之后判断(真实 HTTP) ──────
padded = " " * 25 + "A" * 40 + " " * 25
st, body = http(base, "POST", api + "/jobs", token=tok_a,
body=dict(base_job, title="福利归一化", benefits=[padded] * 21))
normal = data_of(body) or {}
normal_id = normal.get("id")
record("J", "创建允许原始超20项及trim前超40字", ["A" * 40], normal.get("benefits"),
st == 200 and normal.get("benefits") == ["A" * 40])
st, body = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a,
body={"benefits": [padded] * 21})
record("J", "更新同样按归一化后限制", ["A" * 40], (data_of(body) or {}).get("benefits"),
st == 200 and (data_of(body) or {}).get("benefits") == ["A" * 40])
st, _ = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a,
body={"benefits": ["A" * 41]})
record("J", "trim后41字拒绝", 400, st, st == 400)
twenty = ["B" + str(i) for i in range(20)]
st, body = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a, body={"benefits": twenty})
record("J", "20项不同福利允许", twenty, (data_of(body) or {}).get("benefits"),
st == 200 and (data_of(body) or {}).get("benefits") == twenty)
st, _ = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a,
body={"title": "不可部分更新", "benefits": twenty + ["B20"]})
record("J", "21项不同福利拒绝", 400, st, st == 400)
st, body = http(base, "GET", api + "/jobs/" + str(normal_id), token=tok_a)
preserved = data_of(body) or {}
record("J", "拒绝后无部分更新", "原题及20项福利", preserved.get("title"),
st == 200 and preserved.get("title") == "福利归一化" and preserved.get("benefits") == twenty)
st, body = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a, body={"benefits": None})
record("J", "null保留福利", twenty, (data_of(body) or {}).get("benefits"),
st == 200 and (data_of(body) or {}).get("benefits") == twenty)
st, body = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a, body={"benefits": []})
record("J", "空数组清空福利", [], (data_of(body) or {}).get("benefits"),
st == 200 and (data_of(body) or {}).get("benefits") == [])
# ── 持久化用夹具(供重启后核对) ───────────────────────────
mysql("UPDATE companies SET description='B01PERSIST' WHERE id=%s" % cid)
return {"companyId": cid, "jobId": jid, "city": city, "token": tok_a, "uid": uid_a}
def persist_create(base, api):
seq = int(time.time()) % 100000
tok, uid = as_company(base, phone(seq))
st, body = http(base, "POST", api + "/companies/me", token=tok,
body={"name": "B01PERSIST企业", "companySize": "500-999人",
"description": "B01PERSIST"})
cid = (data_of(body) or {}).get("id")
mysql("UPDATE companies SET verified=1 WHERE id=%s" % cid)
st, body = http(base, "POST", api + "/jobs", token=tok, body={
"title": "B01PERSIST岗位", "description": "d", "city": "B01PERSISTCITY",
"employmentType": "FULL_TIME", "experience": "5-10年", "salaryMonths": 16,
"benefits": ["五险一金"], "workLocation": "上海市"})
jid = (data_of(body) or {}).get("id")
print("PERSIST_CREATED companyId=%s jobId=%s" % (cid, jid))
return cid, jid
def persist_verify(base, api):
cid = scalar("SELECT id FROM companies WHERE name='B01PERSIST企业'")
jid = scalar("SELECT id FROM jobs WHERE title='B01PERSIST岗位'")
st, body = http(base, "GET", api + "/companies/" + str(cid))
record("I", "重启后企业资料仍在", "B01PERSIST企业/500-999人",
"%s/%s" % ((data_of(body) or {}).get("name"), (data_of(body) or {}).get("companySize")),
(data_of(body) or {}).get("name") == "B01PERSIST企业"
and (data_of(body) or {}).get("companySize") == "500-999人")
mysql("UPDATE jobs SET status='PUBLISHED', published_at=NOW() WHERE id=%s" % jid)
st, body = http(base, "GET", api + "/jobs/public/" + str(jid))
node = data_of(body) or {}
record("I", "重启后岗位展示字段仍在", "5-10年/16/[五险一金]",
"%s/%s/%s" % (node.get("experience"), node.get("salaryMonths"), node.get("benefits")),
node.get("experience") == "5-10年" and node.get("salaryMonths") == 16
and node.get("benefits") == ["五险一金"])
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--base", default="http://127.0.0.1:18080")
parser.add_argument("--api", default="/api/v1")
parser.add_argument("--mode", default="full",
choices=["full", "persist-create", "persist-verify"])
parser.add_argument("--out")
args = parser.parse_args()
global API
API = args.api
base = args.base.rstrip("/")
if args.mode == "persist-create":
persist_create(base, args.api)
return
if args.mode == "persist-verify":
persist_verify(base, args.api)
else:
summary = run(base, args.api)
print("FIXTURE companyId=%s jobId=%s" % (summary["companyId"], summary["jobId"]))
bad = [r for r in rows if not r["ok"]]
print("\n== BLACKBOX SUMMARY: total=%d fail=%d ==" % (len(rows), len(bad)))
if args.out:
with open(args.out, "w", encoding="utf-8") as fh:
json.dump(rows, fh, ensure_ascii=False, indent=1)
sys.exit(1 if bad else 0)
if __name__ == "__main__":
main()

140
docs/acceptance/batch-display-fields-20261016/b01_contract_negative_tests.py

@ -0,0 +1,140 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""本批 OpenAPI 契约对照脚本的**反向测试**。
通过在**临时副本**中分别制造下列变异,证明严格对照脚本在每种情况下都会失败
(进程退出码非 0);**不修改正式契约**来制造这些测试:
1. 删除一个约束(JobCreateRequest.experience.maxLength)
2. 改错类型(JobCreateRequest.salaryMonths.type: integer → string)
3. 改错范围(JobCreateRequest.salaryMonths.maximum: 24 → 12)
4. 移除字段(JobCreateRequest.properties.education)
5. 加入敏感字段(CompanyPublicView.properties.ownerUserId)
6. 改错可空性(JobCreateRequest.experience.type 去掉 "null")
7. 改错数组元素长度(benefits.items.maxLength: 40 → 41)
8. 改错响应引用(/jobs/public/{jobId} 200 引用改为 ApiResponseJobDetailViewDoc)
用法:
python b01_contract_negative_tests.py [--runtime PATH] [--static PATH]
退出码:0=全部(基线通过 + 各变异均失败);1=存在不符合预期的结果。
"""
import argparse
import copy
import json
import subprocess
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[2]
COMPARE = HERE / "b01_openapi_compare.py"
DEFAULT_STATIC = ROOT / "docs" / "openapi-rc.json"
DEFAULT_RUNTIME = HERE / "runtime-openapi.json"
results = []
def run_compare(static_path, runtime_path, out_path):
proc = subprocess.run(
[sys.executable, str(COMPARE), "--static", str(static_path),
"--runtime", str(runtime_path), "--out", str(out_path),
"--runtime-out", str(out_path)],
capture_output=True, text=True, encoding="utf-8", errors="replace")
return proc.returncode, proc.stdout
def mutate_remove_max_length(doc):
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["experience"].pop("maxLength", None)
def mutate_wrong_type(doc):
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["salaryMonths"]["type"] = ["string", "null"]
def mutate_wrong_range(doc):
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["salaryMonths"]["maximum"] = 12
def mutate_remove_field(doc):
doc["components"]["schemas"]["JobCreateRequest"]["properties"].pop("education", None)
def mutate_sensitive_field(doc):
doc["components"]["schemas"]["CompanyPublicView"]["properties"]["ownerUserId"] = {"type": "integer"}
def mutate_nullability(doc):
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["experience"]["type"] = "string"
def mutate_item_length(doc):
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"]["x-normalization"]["itemMaxLength"] = 41
def mutate_normalization_removed(doc):
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"].pop("x-normalization")
def mutate_trim(doc):
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"]["x-normalization"]["trimItems"] = False
def mutate_raw_limit(doc):
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"]["maxItems"] = 20
def mutate_benefits_null(doc):
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"]["type"] = "array"
def mutate_response_ref(doc):
doc["paths"]["/jobs/public/{jobId}"]["get"]["responses"]["200"]["content"]["*/*"]["schema"]["$ref"] = \
"#/components/schemas/ApiResponseJobDetailViewDoc"
MUTATIONS = [
("① 删除约束 experience.maxLength", mutate_remove_max_length),
("② 改错类型 salaryMonths→string", mutate_wrong_type),
("③ 改错范围 salaryMonths.maximum=12", mutate_wrong_range),
("④ 移除字段 education", mutate_remove_field),
("⑤ 加入敏感字段 ownerUserId", mutate_sensitive_field),
("⑥ 改错可空性 experience 去掉 null", mutate_nullability),
("⑦ 改错数组元素长度 items.maxLength=41", mutate_item_length),
("⑧ 改错响应引用 public job", mutate_response_ref),
("⑨ 移除归一化规则", mutate_normalization_removed),
("⑩ 错误关闭trim", mutate_trim),
("⑪ 错误限制原始请求20项", mutate_raw_limit),
("⑫ 错误拒绝福利null", mutate_benefits_null),
]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--static", default=str(DEFAULT_STATIC))
parser.add_argument("--runtime", default=str(DEFAULT_RUNTIME))
args = parser.parse_args()
baseline = json.load(open(args.static, encoding="utf-8"))
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
# 0) 基线(未变异)必须通过
out0 = tmp / "compare-baseline.json"
rc, _ = run_compare(args.static, args.runtime, out0)
results.append(("基线(正式契约 + 运行时)通过", rc == 0, "rc=%d" % rc))
# 各变异必须失败(rc != 0)
for name, mutate in MUTATIONS:
doc = copy.deepcopy(baseline)
mutate(doc)
mutated = tmp / "static-mutated.json"
mutated.write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding="utf-8")
out = tmp / "compare.json"
rc, stdout = run_compare(mutated, args.runtime, out)
results.append(("%s → 对照脚本失败" % name, rc != 0, "rc=%d" % rc))
bad = [r for r in results if not r[1]]
for name, ok, detail in results:
print("[%s] %-46s %s" % ("PASS" if ok else "FAIL", name, detail))
print("\n== CONTRACT NEGATIVE SUMMARY: total=%d fail=%d ==" % (len(results), len(bad)))
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())

249
docs/acceptance/batch-display-fields-20261016/b01_openapi_compare.py

@ -0,0 +1,249 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""本批 JAR 的运行时 /v3/api-docs 与静态契约 docs/openapi-rc.json 的**严格**对照。
对照范围(整改项 2):
* 具体路径 + 方法 + 200 响应 Schema 引用(逐 operation 核对,不只看路径数量)。
* 展示字段的 类型 / 数组元素类型 / 范围 / 长度 / 数量 / 可空性,在静态与运行时之间逐项比较;
任一必需项不一致即失败(不再把「约束缺失」判为通过,取消 “static-only 即通过”)。
* Map 型岗位响应的**文档模型**(ApiResponseJobDetailViewDoc / ApiResponseJobListViewDoc /
ApiResponseJobPublicViewDoc 及其 data)结构与静态一致。
* 公开企业摘要严格 7 字段白名单;多出敏感字段即失败。
用法:
python b01_openapi_compare.py [base-url] [--static PATH] [--runtime PATH] [--out PATH]
退出码:0=全部通过;1=存在失败。
"""
import argparse
import json
import sys
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
DEFAULT_STATIC = ROOT / "docs" / "openapi-rc.json"
ACCEPT_DIR = Path(__file__).resolve().parent
DEFAULT_RUNTIME_OUT = ACCEPT_DIR / "runtime-openapi.json"
DEFAULT_COMPARE_OUT = ACCEPT_DIR / "openapi-compare.json"
# 逐 operation 期望的响应 Schema 引用(要求为各操作专属文档模型,不得共用 MapStringObject)。
EXPECTED_REFS = {
("/jobs", "get"): "ApiResponseJobListViewDoc",
("/jobs", "post"): "ApiResponseJobDetailViewDoc",
("/jobs/{jobId}", "get"): "ApiResponseJobDetailViewDoc",
("/jobs/{jobId}", "put"): "ApiResponseJobDetailViewDoc",
("/jobs/public/{jobId}", "get"): "ApiResponseJobPublicViewDoc",
}
PUBLIC_COMPANY_FIELDS = ["companySize", "description", "id", "industry", "logoUrl", "name", "verified"]
FORBIDDEN_PUBLIC_FIELDS = ["ownerUserId", "address", "website", "phone", "email", "createdAt",
"updatedAt", "materialsJson", "decisionReason", "reviewerId"]
DOC_SCHEMAS = ["JobDocCompanySummary", "JobDetailViewDoc", "JobPublicViewDoc", "JobListViewDoc",
"ApiResponseJobDetailViewDoc", "ApiResponseJobPublicViewDoc", "ApiResponseJobListViewDoc"]
JOB_DETAIL_FIELDS = ["id", "companyId", "title", "description", "requirements", "city",
"employmentType", "salaryMin", "salaryMax", "experience", "education",
"benefits", "salaryMonths", "workLocation", "status", "publishedAt",
"createdAt", "updatedAt"]
JOB_PUBLIC_FIELDS = ["id", "companyId", "title", "description", "requirements", "city",
"employmentType", "salaryMin", "salaryMax", "experience", "education",
"benefits", "salaryMonths", "workLocation", "status", "publishedAt",
"createdAt", "company"]
JOB_LIST_FIELDS = ["items", "total", "page", "size", "totalPages"]
rows = []
def record(case, expected, actual, ok):
rows.append({"case": case, "expected": str(expected)[:200], "actual": str(actual)[:200],
"ok": bool(ok)})
print("[%s] %-62s exp=%-20s act=%s" % ("PASS" if ok else "FAIL", case,
str(expected)[:20], str(actual)[:70]))
def props(node):
return (node or {}).get("properties", {})
def canon_type(node):
value = (node or {}).get("type")
if value is None:
return None
return tuple(sorted(value)) if isinstance(value, list) else value
def is_nullable(node):
value = (node or {}).get("type")
if isinstance(value, list) and "null" in value:
return True
return bool((node or {}).get("nullable"))
def get(node, path):
cur = node
for token in path.split("."):
if not isinstance(cur, dict):
return None
cur = cur.get(token)
return cur
def cmp_field(case_prefix, s_node, r_node, aspects):
"""逐 aspect 比较静态/运行时字段节点;缺失即失败。"""
if s_node is None or r_node is None:
record("%s 存在(静态/运行时)" % case_prefix, "present", "s=%s r=%s" % (s_node is not None, r_node is not None),
s_node is not None and r_node is not None)
return
for path, kind in aspects:
sv, rv = get(s_node, path), get(r_node, path)
if kind == "type":
sv, rv = canon_type({"type": sv}), canon_type({"type": rv})
elif kind == "nullable":
sv, rv = is_nullable(s_node), is_nullable(r_node)
record("%s %s 一致" % (case_prefix, path), sv, rv, sv == rv and sv is not None)
def cmp_ref(op_path, method, static, runtime):
def ref(doc):
return doc["paths"].get(op_path, {}).get(method, {}).get(
"responses", {}).get("200", {}).get("content", {}).get("*/*", {}).get(
"schema", {}).get("$ref", "")
s_ref, r_ref = ref(static), ref(runtime)
expected = EXPECTED_REFS[(op_path, method)]
record("%s %s 运行时 200 引用 = %s" % (method.upper(), op_path, expected),
"#/components/schemas/" + expected, r_ref, r_ref.endswith(expected))
record("%s %s 静态/运行时 200 引用一致" % (method.upper(), op_path), s_ref, r_ref,
s_ref == r_ref and s_ref != "")
record("%s %s 未共用 ApiResponseMapStringObject" % (method.upper(), op_path),
"专属模型", r_ref, "MapStringObject" not in r_ref)
name = expected
in_static = name in static["components"]["schemas"]
in_runtime = name in runtime["components"]["schemas"]
record("%s %s 引用组件双方存在" % (method.upper(), op_path), True,
"static=%s runtime=%s" % (in_static, in_runtime), in_static and in_runtime)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("base", nargs="?", default="http://127.0.0.1:18080")
parser.add_argument("--static", default=str(DEFAULT_STATIC))
parser.add_argument("--runtime", default=None)
parser.add_argument("--out", default=str(DEFAULT_COMPARE_OUT))
parser.add_argument("--runtime-out", default=str(DEFAULT_RUNTIME_OUT))
args = parser.parse_args()
static = json.load(open(args.static, encoding="utf-8"))
if args.runtime:
runtime = json.load(open(args.runtime, encoding="utf-8"))
else:
url = args.base.rstrip("/") + "/api/v1/v3/api-docs"
with urllib.request.urlopen(url, timeout=90) as resp:
runtime = json.loads(resp.read().decode("utf-8"))
Path(args.runtime_out).write_text(json.dumps(runtime, ensure_ascii=False, indent=2),
encoding="utf-8")
s_schemas, r_schemas = static["components"]["schemas"], runtime["components"]["schemas"]
s_paths, r_paths = static.get("paths", {}), runtime.get("paths", {})
# 1) 基线:路径数与 operation 数一致
record("运行时路径数 == 静态契约", len(s_paths), len(r_paths), len(s_paths) == len(r_paths))
record("运行时 operation 数 == 静态契约",
sum(len(v) for v in s_paths.values()), sum(len(v) for v in r_paths.values()),
sum(len(v) for v in s_paths.values()) == sum(len(v) for v in r_paths.values()))
# 2) 逐 operation 响应引用
for (path, method) in EXPECTED_REFS:
cmp_ref(path, method, static, runtime)
# 3) 请求 DTO 展示字段约束
text_aspects = [("type", "type"), ("maxLength", "raw"), ("nullable", "nullable")]
for name in ("JobCreateRequest", "JobUpdateRequest"):
sp, rp = props(s_schemas.get(name)), props(r_schemas.get(name))
for field in ("experience", "education"):
cmp_field("%s.%s" % (name, field), sp.get(field), rp.get(field), text_aspects)
cmp_field("%s.workLocation" % name, sp.get("workLocation"), rp.get("workLocation"),
[("type", "type"), ("maxLength", "raw"), ("nullable", "nullable")])
cmp_field("%s.benefits" % name, sp.get("benefits"), rp.get("benefits"),
[("type", "type"), ("nullable", "nullable"), ("items.type", "raw"),
("x-normalization", "raw")])
for label, node in (("静态", sp.get("benefits") or {}), ("运行时", rp.get("benefits") or {})):
expected = dict(trimItems=True, deduplicate=True, rejectBlankItems=True, maxItems=20, itemMaxLength=40)
record("%s %s 福利归一化语义" % (label, name), expected, node.get("x-normalization"),
node.get("x-normalization") == expected)
record("%s %s 不限制原始数组数量或未trim长度" % (label, name), "absent",
(node.get("maxItems"), node.get("items", {}).get("maxLength")),
"maxItems" not in node and "maxLength" not in node.get("items", {}))
record("%s %s 福利可空" % (label, name), True, is_nullable(node), is_nullable(node))
cmp_field("%s.salaryMonths" % name, sp.get("salaryMonths"), rp.get("salaryMonths"),
[("type", "type"), ("minimum", "raw"), ("maximum", "raw"), ("nullable", "nullable")])
for field in ("salaryMin", "salaryMax"):
record("%s.%s 双方存在且为 integer" % (name, field), "integer",
canon_type(rp.get(field)), canon_type(sp.get(field)) == "integer"
and canon_type(rp.get(field)) == "integer")
# 4) 企业请求 / 公开摘要的 companySize
for name in ("CompanyCreateRequest", "CompanyUpdateRequest", "CompanyPublicView"):
cmp_field("%s.companySize" % name,
props(s_schemas.get(name)).get("companySize"),
props(r_schemas.get(name)).get("companySize"),
[("type", "type"), ("maxLength", "raw"), ("nullable", "nullable")])
# 5) 企业自身岗位管理摘要
for field in ("experience", "education", "workLocation"):
cmp_field("CompanyManagedJobItem.%s" % field,
props(s_schemas.get("CompanyManagedJobItem")).get(field),
props(r_schemas.get("CompanyManagedJobItem")).get(field),
[("type", "type"), ("maxLength", "raw")])
cmp_field("CompanyManagedJobItem.benefits",
props(s_schemas.get("CompanyManagedJobItem")).get("benefits"),
props(r_schemas.get("CompanyManagedJobItem")).get("benefits"),
[("type", "type"), ("maxItems", "raw"), ("items.type", "raw"), ("items.maxLength", "raw")])
cmp_field("CompanyManagedJobItem.salaryMonths",
props(s_schemas.get("CompanyManagedJobItem")).get("salaryMonths"),
props(r_schemas.get("CompanyManagedJobItem")).get("salaryMonths"),
[("type", "type"), ("minimum", "raw"), ("maximum", "raw")])
# 6) 公开企业摘要严格 7 字段白名单(静态与运行时)
for label, schemas in (("静态", s_schemas), ("运行时", r_schemas)):
node = props(schemas.get("CompanyPublicView"))
record("CompanyPublicView %s 严格 7 字段" % label, PUBLIC_COMPANY_FIELDS,
sorted(node.keys()), sorted(node.keys()) == PUBLIC_COMPANY_FIELDS)
leaked = [f for f in FORBIDDEN_PUBLIC_FIELDS if f in node]
record("CompanyPublicView %s 无敏感字段" % label, "无", leaked, not leaked)
# 7) 文档模型:双方存在、属性集合与类型一致
for name in DOC_SCHEMAS:
s_node, r_node = s_schemas.get(name), r_schemas.get(name)
record("文档模型 %s 双方存在" % name, True, "static=%s runtime=%s" % (s_node is not None, r_node is not None),
s_node is not None and r_node is not None)
if s_node is None or r_node is None:
continue
record("文档模型 %s 属性集合一致" % name, sorted(props(s_node).keys()),
sorted(props(r_node).keys()), sorted(props(s_node).keys()) == sorted(props(r_node).keys()))
mismatched = [k for k in props(s_node)
if canon_type(props(s_node)[k]) != canon_type(props(r_node).get(k))]
record("文档模型 %s 逐属性类型一致" % name, "无差异", mismatched, not mismatched)
# 8) 文档模型语义:必要 data 字段
for schema_name, fields in (("JobDetailViewDoc", JOB_DETAIL_FIELDS),
("JobPublicViewDoc", JOB_PUBLIC_FIELDS),
("JobListViewDoc", JOB_LIST_FIELDS)):
for label, schemas in (("静态", s_schemas), ("运行时", r_schemas)):
missing = [f for f in fields if f not in props(schemas.get(schema_name))]
record("%s %s 必需字段齐全" % (label, schema_name), fields, missing, not missing)
for label, schemas in (("静态", s_schemas), ("运行时", r_schemas)):
node = props(schemas.get("JobDocCompanySummary"))
record("%s JobDocCompanySummary 严格 7 字段" % label, PUBLIC_COMPANY_FIELDS,
sorted(node.keys()), sorted(node.keys()) == PUBLIC_COMPANY_FIELDS)
for label, schemas in (("静态", s_schemas), ("运行时", r_schemas)):
b = props(schemas.get("JobPublicViewDoc")).get("benefits") or {}
record("%s 响应端 benefits 恒为数组(非可空)" % label, "array", b.get("type"),
b.get("type") == "array")
bad = [r for r in rows if not r["ok"]]
print("\n== OPENAPI COMPARE SUMMARY: total=%d fail=%d ==" % (len(rows), len(bad)))
Path(args.out).write_text(json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())

67
docs/application-reapplication-handover-20261020.md

@ -0,0 +1,67 @@
# 撤回后重新投递:后端与前端联调约定
范围:仅后端重投规则、兼容字段和关联权限;不调整已拒绝后的重投政策,不修改前端,不删除历史数据。测试结果以本批最终验收记录为准;本说明不代表已经上线。
## 1. 重投规则
| 当前情况 | POST /applications |
| --- | --- |
| 没有投递历史 | 创建首轮,SUBMITTED |
| 同岗位所有历史申请均为 WITHDRAWN | 创建新 id、新轮次,SUBMITTED |
| 存在 SUBMITTED/VIEWED/INTERVIEW | 拒绝重复投递 |
| 存在 REJECTED/HIRED | 仍拒绝,本批不放开 |
| 岗位不是 PUBLISHED | 不允许投递,包括撤回后重投 |
撤回仍只允许 SUBMITTED/VIEWED,不放开面试中撤回。旧记录不恢复为 SUBMITTED;每轮有独立时间线,旧匹配、面试、Offer 及简历卡片继续关联旧 id。
V58 新增 attempt_number、previous_application_id 和按状态生成的 active_application_key。旧记录轮次为 1;仅 WITHDRAWN 的占位键为 NULL。数据库同时限制“同岗位同求职者同轮次唯一”和“同岗位同求职者非撤回申请唯一”。投递事务锁定岗位,检查其发布状态、占位及上一轮,防止并发重投。岗位级锁会串行处理同岗位投递,是本批明确的正确性取舍。
## 2. 接口字段与错误码
继续调用 `POST /api/v1/applications`,请求不变:
```json
{"jobId":123}
```
重投成功仍返回 HTTP 200,新响应 `data` 示例(其他既有字段保留):
```json
{"id":456,"jobId":123,"status":"SUBMITTED","attemptNumber":2,"previousApplicationId":321}
```
- `id`:本轮投递 ID。后续评分、企业处理、面试、Offer、会话解锁及简历卡片操作必须使用对应轮次的 ID。
- `attemptNumber`:从 1 开始;用于区分同岗位不同轮次,不代表新的投递状态。
- `previousApplicationId`:直接上一轮 id;首轮为 null,受全局非空序列化影响也可能省略。
- 上述字段下发于本人投递列表/详情、企业投递列表、企业候选人列表/详情。候选人 DTO 不新增手机号、用户 ID、简历存储引用等敏感字段。
- 重复投递沿用 HTTP 409 / `code=8001`,岗位不可投递沿用 HTTP 404 / `code=7001`。不要把所有非 200 都显示为“网络错误”。
- `code=8003`:撤回/状态流转不允许;`8304`:指定轮次未匹配;`8303`:企业与求职者当前没有有效匹配,不能建立/恢复发送。
前端撤回后将该轮显示为“已撤回”,在岗位仍可投递时展示“重新投递”。成功后使用服务端返回的新 id 刷新列表,不覆盖旧记录。防连点只用于体验,后端仍负责并发约束;请求超时后先刷新投递记录,不能擅自推断成功。
同岗位多轮全部保留在列表;判断最新轮次用该岗位的最大 attemptNumber,不依赖秒级 createdAt 相同情况下的数组顺序。
## 3. 旧会话与简历权限
- 新一轮不复制旧匹配分、阈值快照或匹配结果。旧 id 即使保留 MATCHED 历史记录,只要投递已撤回,不能通过该 id 解锁会话。
- 历史 DIRECT 会话可阅读。对有撤回历史的企业/求职者组合,发送消息和通过通用接口获取旧会话,必须存在双方当前有效 MATCHED 申请。重新匹配后可以复用原会话,不复制历史消息。
- 沟通解锁仍采用既有“企业—求职者关系”口径:若双方另一个岗位存在有效匹配,可以继续沟通;不能把它描述为每个岗位拥有完全独立的聊天权限。
- 聊天简历快照继续绑定原 applicationId,撤回后不能因新一轮匹配而恢复旧卡片授权;已显式撤销的卡片不复活。
- 本批不引入“投递时固化简历”新机制。企业当前简历下载仍读取求职者当前资料,并依据指定 applicationId、企业归属与隐私策略授权;主动发送卡片时才保存不可变快照。
- PRIVATE 为动态限制;VERIFIED_COMPANIES 等既有显式隐私授权语义不在本批调整。不可将用户面向认证企业的全局授权误称为旧轮次权限恢复。
- 面试和 Offer 不迁移到新 id,不恢复旧流程。
## 4. 前端联调验收步骤
1. 使用合成账号和已发布岗位首次投递,记录 id、轮次及列表状态。
2. 企业查看后,求职者撤回,确认旧时间线仍在。
3. 同一岗位重投,确认返回新 id、轮次加一、previousApplicationId 指向旧 id,企业收到新的待处理申请。
4. 连续点击/并发请求,确认最多一条有效申请,其余返回重复投递错误。
5. 对进行中、已拒绝、已录用和关闭岗位分别验证禁止重投。
6. 旧轮次匹配不能解锁新轮次;历史聊天可读,新轮次重新满足匹配后才按关系级规则发送。
7. 用真实结构的合成 PDF 验证旧卡片仍拒绝、新卡片绑定新轮次;更换源简历不能篡改旧快照。
8. 重启隔离 JAR 后核对两轮记录、时间线和授权行为。
发布前还需:真实 MySQL 5.7/8 迁移、专项回归、最新 JAR 黑盒与后续全量验证。前端接入与提交/推送/发布均另行安排。
本批本地验证记录(2026-10-20):专项 Maven 测试合计 129 项,0 失败/错误/跳过;新 JAR 黑盒 61 项及重启复验 7 项通过;静态与运行时契约专项 12 项通过。已覆盖真实 MySQL 5.7/8 的 V57→V58 升级及 MySQL 并发。证据与制品在 `D:\Workspaces\qsp-reapply-20261020`。本批完整 `mvn verify` 尚未执行;上述结果不代表线上已经更新。

5
docs/backend-development-roadmap.md

@ -1,4 +1,4 @@
# 职来后端一期交付路线图 # 轻松聘后端一期交付路线图
> 当前范围冻结:2026-09-08。当前一期范围、需求、代码入口、验收及责任的唯一明细为 [一期范围与验收清单](phase1-scope-and-acceptance.md)。下方历史规划不再用于判断当前待办或批准新接口/迁移。 > 当前范围冻结:2026-09-08。当前一期范围、需求、代码入口、验收及责任的唯一明细为 [一期范围与验收清单](phase1-scope-and-acceptance.md)。下方历史规划不再用于判断当前待办或批准新接口/迁移。
@ -6,6 +6,7 @@
| 范围 | 2026-09-08核对结果 | 下一动作 | | 范围 | 2026-09-08核对结果 | 下一动作 |
|---|---|---| |---|---|---|
| N03/N04/N05 模块集成(2026-10-08 增补) | 隔离集成版本全量 `mvn clean verify` 3224/3224,原仓库转入复验 227/227,F/E/S 均为 0;真实 MySQL 独立功能 9/9、迁移及运行时接口集合 2/2;N05 并发幂等阻断已修复;OpenAPI 196/239 | 待独立提交,随后安排部署和前后端联调;薪资政策数据发布前仍需来源复核。下方历史规划原文保留,不作为当前模块进度依据 |
| 核心招聘与账户能力 | 已有代码与历史验收记录;本轮未重跑验收 | 按清单C01–C08做一期集成回归 | | 核心招聘与账户能力 | 已有代码与历史验收记录;本轮未重跑验收 | 按清单C01–C08做一期集成回归 |
| V39–V42 | 已核对提交55e3975、2698d06、5cc2c86、96b3113;不再标为待Git检查点 | 保留回归,不重复开发企业回复/发现/邀约 | | V39–V42 | 已核对提交55e3975、2698d06、5cc2c86、96b3113;不再标为待Git检查点 | 保留回归,不重复开发企业回复/发现/邀约 |
| V43 | 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(cf39737) | N01 已关闭版本内小样本补集泄露;30/300/3000 查询预算通过,EXPLAIN 扩容风险保留观察 | | V43 | 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(cf39737) | N01 已关闭版本内小样本补集泄露;30/300/3000 查询预算通过,EXPLAIN 扩容风险保留观察 |
@ -30,7 +31,7 @@
> >
> 确认日期:2026-08-25 > 确认日期:2026-08-25
> >
> 适用项目:职来(mydreamjob-server) > 适用项目:轻松聘(mydreamjob-server)
> **正式接口文档目录**:`C:\Users\15642\Desktop\zhilai\docs`(OpenAPI、前端联调、验收、各模块 API 文档的唯一正式目录)。 > **正式接口文档目录**:`C:\Users\15642\Desktop\zhilai\docs`(OpenAPI、前端联调、验收、各模块 API 文档的唯一正式目录)。
> 本文档 `mydreamjob-server\docs\backend-development-roadmap.md` 仅保留为「路线图源文件」,不移动、不删除任何文档。 > 本文档 `mydreamjob-server\docs\backend-development-roadmap.md` 仅保留为「路线图源文件」,不移动、不删除任何文档。

266
docs/batch-job-company-display-fields-frontend-handover.md

@ -0,0 +1,266 @@
# 岗位与企业展示字段 · 前端交接说明
> 批次:job-company-display-fields
> 后端基线:`1cbadc3e18a786ef642b0d329adde6b173c7b2ac`(分支 `fix/mysql57-module-migrations-20261009`)
> 数据库迁移:`V57__job_company_display_fields.sql`
> 本文档面向前端工程师,描述本批岗位展示字段的接入参考,不改变既有字段语义。
> 更新:2026-10-19。本地后端验收已通过,但尚未提交、推送或发布;目标服务器是否具备这些字段必须按实际部署版本确认。本文不是前端已经完成接入的证明。
---
## 1. 新字段在哪些端点返回
| 端点 | 新增顶层字段 | `company` 摘要 |
|---|---|---|
| `GET /jobs`(公开列表) | ✅ 5 个 | ✅ 新增 |
| `GET /jobs/search`(公开搜索) | ✅ 5 个 | ✅ 新增(兼容扩展) |
| `GET /jobs/public/{jobId}`(公开详情) | ✅ 5 个 | ✅ 新增 |
| `GET /companies/{companyId}/jobs` | ✅ 5 个 | ✅ 新增 |
| `GET /jobs/{jobId}`(企业端自己看) | ✅ 5 个 | ❌ 不返回 |
| `POST /jobs`、`PUT /jobs/{jobId}`、`PATCH /jobs/{jobId}/status` | ✅ 5 个(响应同企业端视图) | ❌ 不返回 |
| `GET /companies/me/jobs`(企业端岗位管理) | ✅ 5 个 | ❌ 不返回 |
| `GET /companies/{companyId}`(公开企业详情) | `companySize` | — |
| `GET/POST/PUT /companies/me`(企业端自己看) | `companySize` | — |
| `GET /jobs/favorites`(我的收藏) | ✅ 5 个 | ❌ 不返回 |
「5 个」= `experience`、`education`、`benefits`、`salaryMonths`、`workLocation`。
> 说明:收藏列表复用公开视图组装,因此自动带上 5 个新增顶层字段;本批未为收藏域增加 `company` 嵌套。若前端需要企业名,请先用岗位的 `companyId` 调 `GET /companies/{companyId}`。
---
## 2. 字段类型、单位、缺省值与更新语义
### 2.1 岗位新增字段
| 字段 | 类型 | 缺省值 | 写入语义 |
|---|---|---|---|
| `experience` | string \| null | `null` | `null`/缺省 = 保持原值;**空白串 = 清空为 null**;非空 = 去两端空白后保存;最长 40 |
| `education` | string \| null | `null` | 同 `experience`,最长 40 |
| `benefits` | string[] | `[]`(**永不返回 null**) | `null`/缺省 = 保持原值;**`[]` = 清空**;非空 = 逐项 trim → 拒绝空白项 → 单项 ≤40 → **去重且保留首次出现顺序** → 最多 20 项;违反任一条返回 400 |
| `salaryMonths` | int \| null | `null` | `null`/缺省 = 保持原值(**本批不提供显式清空**);非 null 必须 **1—24**,**`0` 视为非法**返回 400;**只接受 JSON 整数——小数(`1.9`/`1.0`)、数字字符串(`"12"`/`""`)、布尔、数组、对象、超出 int 范围一律 400**,绝不截断或四舍五入 |
| `workLocation` | string \| null | `null` | 同 `experience`,最长 255 |
### 2.2 企业新增字段
| 字段 | 类型 | 缺省值 | 写入语义 |
|---|---|---|---|
| `companySize` | string \| null | `null` | `null`/缺省 = 保持原值;**空白串 = 清空为 null**;非空 = trim 后保存;**超过 40 字符返回 400,不截断** |
上述「缺省/null 保持原值」针对更新;创建时没有旧值,对应可选字段存 NULL。响应中的福利始终为数组,请求中的 `benefits` 可以为 null。
### 2.3 福利归一化与 OpenAPI 3.1
- 去重后的数量上限为 20,不是原始请求数组上限:21 个相同标签会成为 1 项并可接受,21 个不同标签返回 400。
- 单项长度在 trim 后判断;空白项拒绝,不是自动删去。
- 更新 `benefits: null` 保留原值,`benefits: []` 清空。不要把这两种请求合并。
- 静态及运行时契约用 `x-normalization` 描述 `trimItems`、`deduplicate`、`rejectBlankItems`、`maxItems=20`、`itemMaxLength=40`,不对原始请求误加 `maxItems`/元素 `maxLength`。
- 这是项目的业务扩展,通用 OpenAPI 客户端不会自动执行归一化。若前端加校验,应按相同顺序处理;后端仍是最终校验方。
- 运行时为 OpenAPI 3.1,可空字段使用包含 null 的联合类型,不以缺少 OpenAPI 3.0 的 `nullable` 判为不可空。
### 2.4 单位与语义(**保持不变**)
- `salaryMin` / `salaryMax`:**单位仍是「元/月」**,本批未改成 K。前端展示 K 时自行 `/1000`(既有约定)。
- 行业仍来自 `companies.industry`,**不在岗位表重复存储**。
### 2.5 只读约定
- `benefits` 读取时:库中为 `NULL` → 返回 `[]`(不是 `null`)。前端可安全地对它做 `.length` / `.map`,无需判空。
- `company`:企业不存在时返回 `null`,**不会编造数据、也不会 500**。前端需容忍 `company === null`。
---
## 3. `company` 嵌套结构 → 前端类型映射
后端 `company` 对象(白名单,**仅这 7 个字段**):
```ts
interface BackendJobCompany {
id: number;
name: string;
logoUrl: string | null;
description: string | null;
industry: string | null;
verified: boolean;
companySize: string | null;
}
```
建议按此前前端类型映射到 `src/types/job.ts`;本轮未修改或重新验收前端,具体类型请以接手工程师当前代码为准:
| 后端字段 | 前端字段 | 备注 |
|---|---|---|
| `company.id` | —(可用于跳企业页) | 前端 `Job` 无对应字段 |
| `company.name` | `Job.companyName` | 已有映射 `company.name → companyName` |
| `company.logoUrl` | — | 前端暂无对应字段 |
| `company.description` | — | 前端暂无对应字段 |
| **`company.industry`** | **`Job.industry`** | 本批接通,取代前端写死的空值 |
| `company.verified` | — | 前端暂无对应字段 |
| **`company.companySize`** | **`JobDetail.companySize`** | 本批接通 |
| `job.workLocation` | (见 §4) | |
| `job.city` | `Job.city` | 既有 |
| `job.title` | `Job.positionName` | 既有 |
| `job.salaryMin/salaryMax` | `Job.salaryMin/salaryMax` | **单位元→K 需 /1000** |
| `job.experience` | `Job.experience` | 本批接通 |
| `job.education` | `Job.education` | 本批接通 |
| `job.benefits` | `Job.benefits` | 本批接通 |
| `job.salaryMonths` | `JobDetail.salaryMonths` | 本批接通(展示为「N 薪」) |
| `job.description` / `requirements` | `JobDetail.description` / `requirements` | 既有,原样保留换行、不截断 |
**逐条要点**:
1. `company.industry` → 前端 `industry`:直接取 `company.industry`,缺失时为 `null`。
2. `company.companySize` → 前端 `companySize`:直接取 `company.companySize`,缺失时为 `null`。
3. 顶层 `experience`、`education`、`benefits`、`salaryMonths` 直接映射到同名前端字段。
---
## 4. `workLocation` 的处理
- `workLocation` 是**岗位明确录入的工作地点**,独立于 `city`。
- 若 `workLocation` 缺失(`null`),**前端可退化为展示 `city`**。
- **但不得据此回写**:不要因为展示用了 `city`,就把 `city` 当作详细办公地址保存或提交回后端。后端的「企业登记地址」(`companies.address`)也**不会被**当作公开招聘地址下发,前端同样不要拿它当工作地点展示。
---
## 5. 评分与评价:继续调用既有真实接口
本批**没有**新增/修改任何评分、评价、排名相关字段或端点。前端请继续使用既有接口:
- 岗位个性化评分:`GET /jobs/{jobId}/personalized-review-score`
- 岗位评价列表 / 汇总:`GET /jobs/{jobId}/reviews`、`GET /jobs/{jobId}/reviews/summary`
本批也**不补造**综合评分、浏览量、排名或员工评价——这些字段若前端仍写死,属前端侧待接,不是后端缺字段。
---
## 6. 前端工程师需核对的字段映射
以下字段在本批后端验收制品中可返回。此前交接发现部分前端映射为空;接手工程师需核对当前实现,不能由本文件推断前端仍未接入或服务器已发布:
| 前端字段 | 后端来源 | 备注 |
|---|---|---|
| `Job.experience` | `job.experience` | 本批新增下发 |
| `Job.education` | `job.education` | 本批新增下发 |
| `Job.benefits` | `job.benefits` | 本批新增下发,恒为数组 |
| `Job.industry` | `company.industry` | 本批新增下发 |
| `JobDetail.companySize` | `company.companySize` | 本批新增下发 |
| `JobDetail.salaryMonths` | `job.salaryMonths` | 本批新增下发 |
需接入的展示字段应按上述来源映射;`JobDetail.views`(浏览数)本批**未新增提供**,不要把本地模拟值当服务器真实数据。
---
## 7. 投递 / 沟通按钮
投递、沟通、邀请面试等按钮属于**前端入口**。本批只是补齐后端岗位与企业资料字段,**不会自动生成任何按钮**,也不会新增/修改投递与沟通的接口契约。
---
## 8. 响应示例
### 8.1 新增字段完整(`GET /jobs/public/{jobId}`)
```json
{
"code": 200,
"message": "ok",
"data": {
"id": 101,
"companyId": 5,
"title": "前端开发工程师",
"description": "负责招聘平台 App 的页面开发。",
"requirements": "1. 熟悉 React Native\n2. 熟悉 TypeScript",
"city": "北京",
"employmentType": "FULL_TIME",
"salaryMin": 18000,
"salaryMax": 26000,
"experience": "3-5年",
"education": "本科",
"benefits": ["五险一金", "年终奖", "弹性工作"],
"salaryMonths": 14,
"workLocation": "北京市海淀区中关村大街 1 号 A 座 12 层",
"status": "PUBLISHED",
"publishedAt": "2026-10-01T10:00:00",
"createdAt": "2026-09-30T18:20:00",
"company": {
"id": 5,
"name": "演示示例科技有限公司",
"logoUrl": "https://example.invalid/logo.png",
"description": "非真实招聘,仅用于本地演示。",
"industry": "互联网/IT",
"verified": true,
"companySize": "100-499人"
}
},
"requestId": "app-xxxxxx",
"timestamp": "2026-10-01T10:00:01Z"
}
```
### 8.2 历史数据字段缺失(老岗位,未补录)
```json
{
"code": 200,
"message": "ok",
"data": {
"id": 7,
"companyId": 2,
"title": "后端开发实习生",
"description": "参与接口开发。",
"requirements": null,
"city": "武汉",
"employmentType": "INTERN",
"salaryMin": 3000,
"salaryMax": 5000,
"experience": null,
"education": null,
"benefits": [],
"salaryMonths": null,
"workLocation": null,
"status": "PUBLISHED",
"publishedAt": "2026-09-01T09:00:00",
"createdAt": "2026-08-31T20:00:00",
"company": {
"id": 2,
"name": "演示联合信息科技有限公司",
"logoUrl": null,
"description": null,
"industry": null,
"verified": false,
"companySize": null
}
},
"requestId": "app-yyyyyy",
"timestamp": "2026-10-01T10:00:01Z"
}
```
> 缺失即为 `null` / `[]`,**后端不会填充「本科」「3—5年」「13薪」等默认值**。前端请按缺失处理(隐藏区块或显示「未填写」)。
---
## 9. 兼容性提示
1. **旧请求不受影响**:不传新字段的创建/修改请求行为完全不变。
2. **旧响应字段不变**:`companyId` 等既有字段保留,未删除、未改名、未改类型;`GET /jobs/search` 原 `company` 结构为**兼容扩展**(只在其中新增字段)。
3. **草稿与已关闭岗位的可见性规则不变**:DRAFT 岗位公开详情仍不可见(404)。
4. `error_code` 约定不变:本批所有新增字段的校验失败统一返回既有参数错误响应(`code=1001`)。
## 10. 发布后联调检查(尚未执行)
以下为待办,不是已完成的验收结果。仅在对应后端版本发布后,使用获得授权的测试账号执行。
1. 确认响应来自目标服务而非本地示例;企业名、规模、行业和岗位五字段按实际值显示,缺失时隐藏或提示未填写。
2. 月薪元到 K 的转换只做一次;`salaryMonths: null` 不默认为 13 或 0,不向更新接口提交 0。
3. 检查字段缺省/null/空白/空数组语义,福利重复和 trim 边界与后端一致。
4. 验证公开摘要仅七字段、`company: null` 可正常渲染;不要用企业登记地址代替工作地点。
5. 评分和员工评价继续使用真实接口,不能用演示值补齐;投递和沟通入口按已有业务权限处理。
6. 对参数错误展示可理解提示,失败后重新读取确认没有部分更新;不伪造成功。
## 11. 验收与相关说明
- 最新本地全量 `clean verify`:3464 项通过,失败/错误/跳过均为 0。真实 HTTP 黑盒合计 106 项,其中岗位企业展示 48 项;重启合计 6 项。
- 静态/运行时契约对照 129 项通过,契约反向测试 13 项通过。不是前端端到端验收结果。
- 交付报告:`docs/batch-job-company-display-fields-report.md`;简历权限与文件名规则:`docs/resume-ownership-hardening-20261016.md`。
- 最新证据:`D:/Acceptance/qsp-final-20261019/evidence/acceptance-summary.json`。未发布、未连接生产库、未向线上导入演示企业岗位。

115
docs/batch-job-company-display-fields-report.md

@ -0,0 +1,115 @@
# 岗位与企业展示字段 · 交付报告(2026-10-19 验收更新)
> 批次:job-company-display-fields;关联简历归属、文件名兼容和评分测试时钟修复。
> 结论:冻结的本地后端工作区快照验收通过;尚未提交、推送、发布或完成线上复验。本文替代此前报告中的过期状态,不把本地结果等同于线上结果。
## 0. 本次更新与历史结论纠正
- 全量验证已从历史失败状态更新为 3464 项通过、0 失败、0 错误、0 跳过。
- `benefits` 的归一化约束已在静态与运行时契约中一致表达,不再存在此前标注的 `STATIC_ONLY_CONSTRAINTS` 差异。
- 福利请求限制按 trim、去重后的结果计算,不能用原始请求的 `maxItems: 20` 或原始元素的 `maxLength: 40` 代替。
- 历史报告、旧 JAR 和旧黑盒产物不作为本次验收证据;最新证据目录见第 3、5、6 节。
- 历史上传接口 500 的根因仍未证实。当前验证未复现,不将其直接归因于旧副本不一致。
## 1. 交付范围与身份
- 原工作区:`C:/Users/15642/Desktop/zhilai`,后端目录为 `mydreamjob-server`。
- Git 基线:`1cbadc3e18a786ef642b0d329adde6b173c7b2ac`;分支:`fix/mysql57-module-migrations-20261009`。
- 验收副本:`D:/Acceptance/qsp-final-20261019/app`。验收对象是包含未提交改动的冻结快照,不是仅 checkout 上述 HEAD 的代码。
- 主要内容:企业规模与岗位展示字段、公开企业摘要、严格整数绑定、V57 迁移、契约及演示工具安全测试。
- 关联修复:本人/企业/聊天简历来源归属、中文文件名兼容、评分测试固定时钟。详见 `docs/resume-ownership-hardening-20261016.md`。
- 新增源码、测试和迁移存在未跟踪文件,提交时必须使用精确白名单,不能只提交已跟踪 diff,也不能 `git add .`。
- 精确验收源文件清单与哈希:`D:/Acceptance/qsp-final-20261019/evidence/source-manifest-current.csv`;契约和工具清单:同目录 `contract-tool-manifest.csv`。
## 2. 实现与字段规则
- 企业新增 `companySize`;岗位新增 `experience`、`education`、`benefits`、`salaryMonths`、`workLocation`。详情及列表按对应 DTO 下发,公开 `company` 严格使用七字段白名单。
- `companySize` 创建与更新共用归一化:创建缺省/null 存 NULL,更新缺省/null 保持原值;空白清空,非空 trim,超过 40 字符返回 400,不截断。
- `salaryMonths` 仅接受 JSON 整数 1—24。小数(含 `1.0`)、数字字符串、布尔、数组、对象、超 int 范围均返回 400;仅字段级严格反序列化,未修改全局 Jackson 强制转换规则。
- `benefits` 逐项 trim、拒绝空白项,trim 后单项最多 40 字符,去重保序后最多 20 项。21 个相同标签可归一为 1 项;21 个不同标签拒绝。
- 福利创建缺省/null 存 NULL;更新缺省/null 保持原值;`[]` 清空。读取 NULL 返回 `[]`。
- 岗位列表按页批量查询企业,查询预算测试覆盖空页及分页场景,避免逐岗位查企业的 N+1。
- 只提供字段和接口,不会自动生成前端的投递、沟通或评价按钮,也不会补造评分或评价。
## 3. 测试结果与数量口径
| 验证范围 | 最新结果 |
|---|---|
| 全量 `clean verify` | 3464 项,失败 0,错误 0,跳过 0;BUILD SUCCESS |
| 真实 HTTP 黑盒 | 简历归属 32 + 来源归属与文件名 26 + 岗位企业展示 48 = 106 项通过 |
| 重启验证 | 简历字节与授权 4 + 企业岗位字段 2 = 6 项通过 |
| 静态/运行时契约 | 129 项通过;重启后重复通过,不重复累计 |
| 契约反向测试 | 13 项通过(含正确基线及错误变异拒绝) |
| 工具安全验证 | 隔离 MySQL 61 + 容器生命周期单测 3 = 64 项通过 |
全量日志:`D:/Acceptance/qsp-final-20261019/logs/full-verify-current.log`。本次未使用 skip/ignore 参数,测试报告保留在验收副本的 target 中。
此前 3439 项快照不含后续文件名修复与恢复的三条正式福利 HTTP 测试等变更;源文件差异清单为 `evidence/changes-since-3439-snapshot.csv`。不同快照数字不得混用。未完成的早期执行日志不计入当前通过结果。
评分测试时间依赖已在测试配置中使用固定 Clock,与测试数据同一时刻,并覆盖 30 天边界;未修改生产评分规则。诊断探针保留为历史证据,不加入正式源码全量测试的计数。
## 4. MySQL 迁移与存储决策
- V57 为新增迁移,包含 `companies.company_size` 及岗位五个展示字段对应列;历史 V1—V56 未改。
- 最新全量中的 `JobCompanyDisplayFieldsMysqlMigrationTest`、`JobCompanyDisplayFieldsMysqlValidateTest` 在真实 MySQL 容器执行,不用 H2 代替,也未跳过。
- 测试覆盖 MySQL 5.7/8 的旧库升级、空库迁移、旧数据保留、新字段写读与 `ddl-auto=validate`。
- `benefits_json` 保持 TEXT,沿用仓库兼容性决策;内容仍是服务层控制的 JSON 数组。未宣称使用 MySQL 原生 JSON 列,也未擅自改历史列类型。
- MySQL 5.7 的既有兼容迁移钩子和工具链方言/版本警告仍需在目标部署环境关注;本地验证不替代线上迁移核对。
## 5. 最新验收 JAR
- 路径:`D:/Acceptance/qsp-final-20261019/app/target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。
- SHA256:`60A9EC9340C83E3E748DD6144473C09BB76BC967A7DA5481296F0FF6B715DF46`。
- 构建方式:冻结快照完整 `clean verify` 成功产生制品,不是跳过测试的 package。
- 身份证据:`evidence/jar-sha256.csv`、`source-manifest-current.csv`、`contract-tool-manifest.csv`。
- 发布时须核对实际提交及 Jenkins 构建身份;JAR 哈希差异本身不能证明源码错误或一致,需结合源码清单。
## 6. HTTP 黑盒与重启
- 岗位企业 48 项覆盖字段一致、缺省/清空语义、严格整数绑定、公开摘要白名单、访问控制、福利归一化与失败无部分更新。
- 最新岗位结果:`evidence/display-full.json`;日志:`logs/display-blackbox-complete.log`。先前 40 项执行已被这份 48 项完整结果替代,不另计。
- 简历结果:`evidence/ownership-blackbox-result.json`、`evidence/reader-fix-blackbox-result.json`。
- 重启后检查文件字节一致、授权/撤销状态保持、岗位企业字段持久化;日志为 `logs/resume-persistence.log`、`logs/display-persistence.log`。
- 全部使用隔离环境合成账号和 PDF;未访问真实用户简历或生产数据。
## 7. OpenAPI 与福利归一化契约
- 静态文件:`docs/openapi-rc.json`;静态检查:`docs/verify_openapi.py`。
- 运行时为 OpenAPI 3.1,可空类型使用类型联合(如 `type: [array, 'null']`),不得依赖 3.0 的 `nullable` 写法。
- 福利请求的 `x-normalization`:`trimItems=true`、`deduplicate=true`、`rejectBlankItems=true`、`maxItems=20`、`itemMaxLength=40`。
- 此扩展描述服务端归一化规则,不是通用 JSON Schema 自动验证关键字。前端若实现校验,应按相同步骤归一化后判断,不应对原始数组直接限 20 项。
- 对照脚本要求归一化规则、字段类型、可空性、响应引用及七字段白名单一致;错误变异必须非零退出,不允许宽松静态独有豁免。
- 最新证据:`evidence/openapi-compare.json`、`evidence/openapi-after-restart.json`,日志 `logs/contract-negative.log`、`logs/static-openapi-frozen.log`。
## 8. 演示数据与工具边界
- 工具:`docs/tools/b01_demo_display_data.py`;默认 dry-run,写入/清理需要显式参数。
- 只允许明确的回环地址、端口 3308 和白名单演示库;非白名单目标在连接前拒绝。该工具不能直接用于线上补数。
- 工具账本绑定批次、真实服务器身份、库名、资源 ID、演示键及指纹;同时检查实际用户/企业身份和岗位父子关系,禁止仅凭名称或手机号认领。
- 冲突预检后再写入;业务写入与账本登记同事务。清理仅依据精确归属及依赖检查,遇外部依赖拒绝,不关闭外键或级联清除。
- 安全测试不得删除同名既有容器,只清理本次创建并记录 ID 的测试容器。
- 数据为 3 家虚构企业、12 个明确标记的演示岗位,无真实个人资料及伪造评分、评价。本轮未向线上导入。
- 最新工具结果:`evidence/tool-safety.json`、`logs/tool-safety.log`、`logs/tool-container-guard.log`。
## 9. 前端交接
参见 `docs/batch-job-company-display-fields-frontend-handover.md`。需要前端工程师按目标环境实际响应接入新字段并处理 null/空数组;不能假定本地修复已经发布到服务器。
该说明保留字段映射、单位、更新语义和示例,补充福利归一化及本地/线上边界。简历权限和文件名规则另见简历修复说明,不在岗位说明中混入账号口令。
## 10. 尚待完成与风险
- Git 精确范围确认、提交、推送、Jenkins 发布及线上小范围复验均未执行。
- 前端代码与目标页面本轮未修改、未重新验收;字段映射及账号切换体验须交前端工程师验证。
- 历史缺失展示字段不会自动补录;需要授权后由业务录入真实资料,不能把演示数据作为真实招聘信息。
- 历史污染简历引用、已生成快照未在线审计或自动修复;不得据本地用例宣称线上历史记录已全部清理。
- 旧上传 500 根因仍未证实;当前不复现不等于已证明原因。
- 当前仅完成本地后端验收,不宣称线上、前后端完整联调或生产数据验收通过。
## 11. 边界与证据保留
- 前端未改;两处受保护文件保持既有内容,不还原、不混入本批提交。
- 未暂存、提交、推送、操作 Jenkins 或连接生产数据库;未使用真实账号和简历。
- 未弱化/跳过测试、改历史迁移或执行 Docker 全局清理。
- 最新验收应用与本轮服务容器已停止;测试资源和日志保留,不删除既有容器或卷。
- 汇总证据:`D:/Acceptance/qsp-final-20261019/evidence/acceptance-summary.json`。旧 `logs2`、`logs3`、`logs4` 资料作为历史记录保留,不作为当前验收结论。

68
docs/chat-realtime-api.md

@ -0,0 +1,68 @@
# 轻松聘聊天实时接口
本文补充 `openapi-rc.json` 中的消息发送、事件补拉和消息字段。所有 HTTP 路径以 `/api/v1` 为前缀;下文省略该前缀。
## 连接和发送
通过 STOMP WebSocket 连接 `/api/v1/ws`,在 **STOMP CONNECT 帧**中设置 `Authorization: Bearer <accessToken>`。不得放在 URL 中,refresh token 不可代替 access token。连接成功后订阅唯一用户队列 `/user/queue/chat-events`,收到 `{"type":"CHAT_READY"}` 后开始补拉。业务消息继续通过 HTTP 接口发送,STOMP SEND 不开放。
`POST /conversations/messages` 请求:
```json
{
"conversationId": 1,
"content": "你好",
"clientMessageId": "example-message-001"
}
```
`clientMessageId` 为 1–64 位英文字母、数字、下划线或连字符;为兼容旧客户端可省略,但新客户端必须为一次逻辑发送生成固定标识,并在超时、断线或未收到响应时复用。同一会话、同一发送者下,相同标识和正文返回原消息,不重复生成事件;相同标识搭配不同正文返回 400。撤回后重试也不会恢复旧正文。
消息响应增加 `clientMessageId`(旧消息可为空)和 `version`(消息版本)。`MessageResponse` 在普通会话返回 `senderUserId`;人才邀请会话继续使用 `senderScope` 隐藏用户标识。撤回消息只返回“消息已撤回”占位。
## 事件补拉
`GET /conversations/{id}/events?after=0&size=50` 必须携带普通 HTTP Bearer access token,且当前账号必须属于该会话。`after` 是非负事件游标;`size` 默认 50,合法范围 1–100。
响应的 `data`:
```json
{
"items": [
{
"eventId": 2,
"type": "MESSAGE_CREATED",
"conversationId": 1,
"occurredAt": "2026-10-15T10:00:00",
"actorScope": "OTHER_PARTICIPANT",
"payload": {
"id": 1,
"clientMessageId": "example-message-001",
"version": 0,
"conversationId": 1,
"content": "你好",
"status": "SENT",
"isRead": false
}
}
],
"nextCursor": 2,
"hasMore": false
}
```
事件包括 `MESSAGE_CREATED`、`MESSAGE_READ`、`MESSAGE_RECALLED`。`actorScope` 为 `CURRENT_USER` 或 `OTHER_PARTICIPANT`;`payload.isRead` 表示当前接收用户的已读状态。payload 读取消息当前状态,而不是保存历史正文快照。
客户端按以下顺序恢复:
1. 连接、鉴权、订阅并等待 `CHAT_READY`。
2. 使用该账号、该会话已持久化的补拉游标调用事件接口;首次使用 0。
3. 以 `eventId` 去重,以消息 `id` 和 `version` 合并,避免旧版本覆盖新状态。
4. 本页成功合并后才持久化 `nextCursor`;`hasMore=true` 时继续请求,直到为 false。
5. 补拉期间同时合并实时事件。实时事件 ID 可能乱序,不得直接用其最大值推进补拉游标。同版本的已读状态还需按 `actorScope` 和本地已读操作处理,不能仅按版本丢弃已读事件。
断线后重新获取有效 access token、重连、订阅,再重复上述流程。账号切换必须隔离游标、消息缓存和连接。
## 运行边界
这些接口沿用企业认证、会话成员、屏蔽和现有匹配准入规则;不会改变投递或开聊门槛。目前 WebSocket 使用单实例内存 broker,多实例部署需要共享消息分发能力。Nginx 示例文件须经部署配置和验证后才能使用。

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 保持运行。本批未暂存、提交、推送或部署;真实短信与前端设置密码页面联调仍未执行,不代表线上已有此接口。

17
docs/frontend-integration.md

@ -1,4 +1,4 @@
# 职来后端前端联调说明(RC) # 轻松聘后端前端联调说明(RC)
## 1. API Base URL ## 1. API Base URL
@ -9,6 +9,17 @@
所有接口统一前缀 `/api/v1`,OpenAPI 文档:`GET /api/v1/v3/api-docs`(Swagger UI:`/api/v1/swagger-ui/index.html`)。 所有接口统一前缀 `/api/v1`,OpenAPI 文档:`GET /api/v1/v3/api-docs`(Swagger UI:`/api/v1/swagger-ui/index.html`)。
聊天实时连接、发送幂等、事件补拉和游标规则见 [聊天实时接口](chat-realtime-api.md)。
### 我的投递列表岗位摘要
`GET /applications/me` 保留原有分页包、筛选和投递字段,items 新增以下平铺字段:
`jobTitle`、`companyId`、`companyName`、`city`、`salaryMin`、`salaryMax`、`jobStatus`、`employmentType`。
薪资为元/月,未填写时内部值为 null,响应可能按全局 non_null 配置省略该字段,不要当作 0 元;`status` 仍是投递状态,`jobStatus` 才是岗位状态。
摘要是查询时的当前信息,不是投递时快照;关闭岗位仍保留在本人投递列表中。
历史关联数据缺失时保留投递记录,摘要内部值为 null(响应可能省略字段),前端按缺失/null 显示占位。
后台只对当前页的去重岗位 ID 做一次批量关联查询,空页不查摘要,不需要前端逐条请求岗位详情。
## 2. 后端启动(Windows) ## 2. 后端启动(Windows)
先启动基础设施容器: 先启动基础设施容器:
@ -160,8 +171,8 @@ POST /api/v1/auth/logout header: Authorization: Bearer <accessToken> (
| 401 | 401(安全过滤器) | 未登录 / 无 token | | 401 | 401(安全过滤器) | 未登录 / 无 token |
| 403 | 2002, 8004, 8303, 8402 | 无角色权限 / 越权(含企业归属不匹配) | | 403 | 2002, 8004, 8303, 8402 | 无角色权限 / 越权(含企业归属不匹配) |
| 400 | 1001, 3002-3006, 8404-8408, 8505, 9105 | 参数错误 / 非法请求体 / 资源未命中默认映射 | | 400 | 1001, 3002-3006, 8404-8408, 8505, 9105 | 参数错误 / 非法请求体 / 资源未命中默认映射 |
| 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 8305, 8501 | 资源不存在 | | 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 8305, 8501, 10501, 10601, 10603 | 资源不存在 |
| 409 | 5003, 7003, 8001, 8003, 8301-8302, 8304, 8502-8504, 9101, 9103 | 重复 / 非法状态流转 | | 409 | 5003, 7003, 8001, 8003, 8301-8302, 8304, 8502-8504, 9101, 9103, 10502-10506, 10604-10605 | 重复 / 非法状态流转 / 版本或幂等冲突 |
| 500 | 1002, 9001 | 服务器内部错误 | | 500 | 1002, 9001 | 服务器内部错误 |
**前端策略**:优先判断 HTTP 状态码,再读取 `body.code` 显示业务信息。 **前端策略**:优先判断 HTTP 状态码,再读取 `body.code` 显示业务信息。

2
docs/job-review-api.md

@ -1,6 +1,6 @@
# 岗位评价与八维评分卡接口说明 # 岗位评价与八维评分卡接口说明
> 职来 (mydreamjob) · 岗位评价 + 八维评分卡基础模块 > 轻松聘 (mydreamjob) · 岗位评价 + 八维评分卡基础模块
> 基础路径:`/api/v1` · 评价创建/修改/删除需登录;列表/详情/汇总/对比公开 > 基础路径:`/api/v1` · 评价创建/修改/删除需登录;列表/详情/汇总/对比公开
## 1. 概述 ## 1. 概述

2
docs/match-api.md

@ -1,6 +1,6 @@
# 双向匹配基础模块接口说明 # 双向匹配基础模块接口说明
> 职来 (mydreamjob) · 双向匹配基础流程(规则阈值版)与匹配后沟通解锁 > 轻松聘 (mydreamjob) · 双向匹配基础流程(规则阈值版)与匹配后沟通解锁
> 基础路径:`/api/v1` · 全部接口需 `Authorization: Bearer <accessToken>` > 基础路径:`/api/v1` · 全部接口需 `Authorization: Bearer <accessToken>`
## 1. 概述 ## 1. 概述

2
docs/offer-advance-api.md

@ -1,6 +1,6 @@
# Offer 进阶模块接口说明 # Offer 进阶模块接口说明
> 职来 (mydreamjob) · Offer 进阶能力 > 轻松聘 (mydreamjob) · Offer 进阶能力
> 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>` > 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>`
> 在 [Offer 管理基础模块](offer-api.md) 之上增量开发,未修改任何已验收接口的公共签名。 > 在 [Offer 管理基础模块](offer-api.md) 之上增量开发,未修改任何已验收接口的公共签名。

2
docs/offer-api.md

@ -1,6 +1,6 @@
# Offer 管理模块接口说明 # Offer 管理模块接口说明
> 职来 (mydreamjob) · Offer 管理基础模块 > 轻松聘 (mydreamjob) · Offer 管理基础模块
> 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>` > 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>`
## 1. 概述 ## 1. 概述

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

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

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

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

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

13577
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 全链路三中间件或公网验收。

2
docs/resume-api.md

@ -1,6 +1,6 @@
# 简历版本管理与导出模块接口说明 # 简历版本管理与导出模块接口说明
> 职来 (mydreamjob) · 简历版本管理与导出基础模块 > 轻松聘 (mydreamjob) · 简历版本管理与导出基础模块
> 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>` > 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>`
## 1. 概述 ## 1. 概述

129
docs/resume-ownership-hardening-20261016.md

@ -0,0 +1,129 @@
# 简历文件归属与文件名修复(2026-10-19 验收更新)
> 分支 `fix/mysql57-module-migrations-20261009`,基线 HEAD `1cbadc3e18a786ef642b0d329adde6b173c7b2ac`。
> 本文是简历安全与文件名兼容的参考说明。关联批次已完成冻结快照的本地后端验收;尚未提交、推送、发布或完成线上复验。下述规则不是线上已生效的承诺。
## 1. 漏洞事实
| 环节 | 修复前行为 |
| --- | --- |
| 存储键 | `LocalResumeStorageService.store(userId, file)` 生成 `"{userId}_{UUID8}{ext}"`,**第一个下划线之前的数字即所有者** |
| 客户端可控引用 | `StudentProfileUpdateRequest.resumeUrl`(仅 `@Size(max=500)`)可被客户端任意填写 |
| 落库 | `ProfileService.updateProfile()` 直接 `profile.setResumeUrl(req.getResumeUrl())` |
| 读取 | `ProfileService.getResume()` 按资料引用直接 `storage.read(...)`;`LocalResumeStorageService.read()` 只校验路径越界与文件存在 |
| 删除 / 替换 | `deleteResume()` / `uploadResume()` 同样按该引用处理旧文件 |
结论:**B 只要把自己的资料引用写成 A 的存储键,就能用自己的下载接口读到 A 的简历文件**(独立探针已复现)。
## 2. 调用链梳理
1. **普通资料修改**:`PUT /profile/me` → `ProfileController.updateMyProfile` → `ProfileService.updateProfile`(`@Transactional`,逐字段非空合并 + 保存 + 登记评分刷新)。
2. **简历上传**:`POST /profile/me/resume` → `uploadResume`(锁资料行 → 存新文件 → 注册提交后删旧文件 → 保存 → 提交后删旧文件;失败时清理新文件)。
3. **简历下载**:`GET /profile/me/resume` → `getResume` → `storage.read(resumeUrl)`。
4. **简历删除**:`DELETE /profile/me/resume` → `deleteResume`(先 `chatResumeLifecycle.purgeForUser` 撤销聊天快照 → 清空资料字段 → 提交后删文件)。
5. **简历版本**:`ResumeVersionService.createVersion/updateVersion/deleteVersion/setDefault`,`ResumeExportService`/`ResumeExportWorker` 导出。版本行有 `userId` 且 `requireOwnActiveVersion()` 已做「本人版本」校验;`file_storage_path` 同样由 `store(userId, file)` 生成,**不接受任何客户端引用**。
6. **快照复用**:
- 聊天简历卡片:`ChatResumeService.send` 在读本人资料引用前验证源文件键归属,再将字节写入 `chat_resume_snapshots`;下载继续使用快照授权,不直接读资料引用。
- 企业下载:`CompanyResumeService.load` 先由 `CompanyResumeAccessPolicy` 决定投递关系、隐私及企业授权,再验证源文件键属于该决策中的求职者(不是 HR),最后才读取。
**身份与校验分工**:身份一律来自已验证 JWT/SecurityContext(`CurrentUserAccessor`),无请求参数入口;`UserRoleGuard` 只做角色校验、不做归属校验。
**「事务提交前删除旧文件」问题**:**不存在**。`uploadResume` 的旧文件删除注册在 `afterCommit`(失败时清理本次新文件);`deleteResume` 的文件删除同样 `deleteFileAfterCommit`;`ResumeVersionService` 亦然。
## 3. 修复方案
### 3.1 新增归属校验器(`profile/storage/ResumeOwnershipVerifier.java`,NEW)
- 解析规则:取**第一个下划线之前**的十进制数字作为所有者;与 `userId` **按数值精确比较**(禁止 `startsWith`,避免 `1` 与 `10` 前缀混淆)。
- 失败关闭:`null`/空白、无下划线、前缀非纯数字、下划线后为空、含 `/` `\` `:`(路径穿越/绝对路径/盘符)、含 `..` → 一律视为「无法证明归属」并拒绝。
- `assertOwnedBy()` 抛出 `ErrorCode.FORBIDDEN`(HTTP 403),消息为固定文案,**不回显存储键、磁盘路径、文件名或他人标识**。
- 只做「键字符串归属解析」,**不介入** `ResumeStorageService.read()`——企业按投递关系下载、投递/聊天快照等合法授权流程保持原样通过。
### 3.2 禁止普通资料接口修改简历引用(`ProfileService.updateProfile`)
- 补齐 **STUDENT 角色校验**(此前 `PUT /profile/me` 无角色校验,COMPANY/ADMIN 可创建/篡改学生资料)。
- `req.getResumeUrl() != null`(含空串)→ `BusinessException(BAD_REQUEST)` → **HTTP 400**,且校验发生在任何字段赋值/保存之前,**整次拒绝、无部分更新**。
- 缺省或显式 `null` → 不改变原简历引用(保持兼容)。
- 说明:选择「保留 DTO 字段 + 服务层显式判断」而非 `@JsonIgnore`——`@JsonIgnore` 会让「未提供」与「显式传了非法值」都映射为 `null`,无法区分;本方案用 `!= null` 精确区分:未提供/显式 null 合法放行,任何提供的值一律拒绝。
- 服务层保留该限制,内部调用同样无法绕过;简历引用只能由受控上传/版本/既有业务流程生成。
### 3.3 本人简历路径的归属校验(`ProfileService`)
| 操作 | 校验位置 | 失败语义 |
| --- | --- | --- |
| 下载 `getResume` | 角色 → 资料存在 → `resumeUrl` 非空 → **归属校验** → 才 `storage.read` | 403,**不读文件** |
| 删除 `deleteResume` | 角色 → 资料行锁定 → **归属校验** → 才 `purgeForUser`/清空字段/登记删文件 | 403,资料、聊天快照、文件**均不变**,**不删除他人文件** |
| 替换 `uploadResume` | 角色 → 校验新文件 → 锁定资料 → **旧引用归属校验** → 才 `store(新文件)` | 403,不改资料、不删他人文件、**不留新文件** |
`getResume` 未创建资料 / `resumeUrl` 为空 / 合法引用但文件缺失 → 沿用既有 `PROFILE_NOT_FOUND`(404)。
### 3.4 历史污染引用的后续读取
- `ChatResumeService.send` 在读源文件和生成聊天快照前调用归属校验器,拒绝借本人资料中的他人引用复制简历。
- `CompanyResumeService.load` 在既有企业访问授权通过后,对 `decision.studentUserId()` 做源文件归属校验;不能用当前 HR 的 ID 校验候选人文件。
- 保持 `ResumeStorageService` 与 `LocalResumeStorageService` 实现不变,避免将「必须本人读取」下沉到存储层而破坏合法 HR 下载。
- 不同来源使用不同授权:源文件归属校验不能代替聊天成员、快照撤销或企业投递关系校验。
### 3.5 中文上传文件名与下载响应
- 共享 `ResumeFilenameSanitizer` 对可明确识别的含非 ASCII 字节 `%HH` 序列做一次严格 UTF-8 解码,修复中文名称被再次编码为 `%25E5...` 的问题。
- 字面 `+` 保持为 `+`,不使用会把它变成空格的 `URLDecoder`。
- 纯 ASCII 百分号序列(例如字面 `%20`、`%25`)存在歧义,保守保留;不能理解为任意百分号串都会解码。
- 新上传按解码后的名称执行长度及路径/控制字符检查,非法编码或危险名称拒绝为 400;不递归解码嵌套的非 ASCII 百分号内容。
- 历史文件下载时尽力归一化并净化名称,不重写历史记录、不改存储键、不改文件字节;不能安全解码时保留后净化,而非猜测内容。
- 当前隔离 HTTP 用例覆盖本人和合法企业下载的中文文件名响应;尚未完成真实浏览器/前端目标版本的端到端验收。
## 4. 错误码与接口兼容
- 普通资料写入非法 `resumeUrl` → **HTTP 400 / code 1001**(沿用统一参数错误结构,未新增错误码)。
- **已登录用户访问不属于自己的资源 → 403 / code 2002**(选择理由:与项目既有语义一致——`ResumeVersionService.requireOwnActiveVersion` 对他人版本返回 `FORBIDDEN`,`CompanyResumeAccessPolicy` 对越权企业同样返回 `FORBIDDEN`;且在日志中可与企业下载等正常授权区分)。
- 接口形态未变:`PUT /profile/me` 的响应结构、`GET /profile/me` 的响应字段(含 `resumeUrl`)保持原样,未新增/删除字段;`PUT /profile/me` 的 Swagger `@Operation` 描述已补充「resumeUrl 只读」约束。
- 未修改共享的 `docs/openapi-rc.json`(该文件已被其他批次改动,避免混入本次修复)。
## 5. 兼容性边界
- **历史键**:当前本地存储实现生成 `userId_UUID8.ext`。可证明归属且格式合法的键继续使用;不能证明归属的历史引用失败关闭。未审计线上全部历史记录,不宣称所有历史键均符合该格式。
- **版本文件**:`resume_versions.file_storage_path` 由同一 `store()` 生成、格式一致;版本操作另有「版本行所属用户」校验,本轮未改动,合法版本/默认简历/导出行为不回退。
- **其他存储类型**:目前只有本地实现。若将来引入对象存储等新键格式,必须同步扩展 `ResumeOwnershipVerifier` 的解析规则,否则会失败关闭。
- **历史污染引用**:本轮**只做安全拒绝**,不自动删除、重绑、迁移或修复历史记录;也未新增任何联网排查工具。
## 6. 测试与验证(隔离环境合成数据)
最新验收基于 `D:/Acceptance/qsp-final-20261019/app` 冻结快照,不复用旧副本的测试结论。
- 归属校验器单测、服务层测试和真实 SecurityFilterChain/Controller 参数绑定的 HTTP 测试全部纳入全量执行。
- `ResumeOwnershipHttpIntegrationTest` 现含新增文件名兼容用例;另有 `ResumeFilenameEncodingTest`、企业下载回归与 `HistoricalResumeOwnershipIntegrationTest` 覆盖历史污染源及合法授权。
- 跨账号拒绝路径检查文件、数据库状态及读写调用;中文文件名测试检查下载响应、字节一致以及旧记录不被回写。
- 全量 `clean verify`:3464 项、0 失败、0 错误、0 跳过,BUILD SUCCESS。该数是全部后端测试总量,不是简历专项数。
- 日志:`D:/Acceptance/qsp-final-20261019/logs/full-verify-current.log`。
历史说明:旧副本曾出现上传返回 500、HTTP 类 7/11 失败;当前快照未复现,根因未证实,不作绝对归因。旧漏洞探针作为诊断历史证据保留,不改弱断言,不将其当作正式源码新增回归计数。
## 7. 最新 JAR 黑盒与重启验证
- JAR:`D:/Acceptance/qsp-final-20261019/app/target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。
- SHA256:`60A9EC9340C83E3E748DD6144473C09BB76BC967A7DA5481296F0FF6B715DF46`。
- 简历归属真实 HTTP 黑盒 32 项通过;补充的来源归属与文件名黑盒 26 项通过。岗位展示另有 48 项,共 106 项,不混算为简历专项。
- 重启后简历源文件与聊天快照字节一致、已撤销卡片继续拒绝、非成员继续拒绝,共 4 项;另有岗位持久化 2 项。
- 本轮应用使用隔离业务端口 18119、管理端口 18120 与独立 MySQL/Redis/RabbitMQ,仅合成账号和 PDF,不访问生产资源。
- 结果:`evidence/ownership-blackbox-result.json`、`evidence/reader-fix-blackbox-result.json`;重启日志:`logs/resume-persistence.log`,均相对于上述验收目录。
- 源码与工具身份由 `evidence/source-manifest-current.csv`、`evidence/contract-tool-manifest.csv` 和 `evidence/jar-sha256.csv` 固定;验收结束时漂移为 0。
## 8. 未完成项与权限边界
- 尚未提交、推送、Jenkins 发布、线上验证或验收前端账号切换;本地通过不代表线上修复已生效。
- 历史污染引用只安全拒绝,没有自动清理、重绑或迁移;已生成聊天快照未在线审计,不能宣称线上历史泄露风险全部排除。
- 个人数据导出的 `PersonalDataCollector.safeRead` 当前输入来自简历版本的 `fileStoragePath`,不是此前误写的本人资料 `resumeUrl`。本轮未证明该路径存在相同漏洞,也未修改该模块;不得仅因同名 read 方法就下漏洞结论。
- 对象存储等未来键格式必须同时调整归属策略与测试;当前无法证明归属的键继续失败关闭。
- 评分测试时钟依赖已单独修复并纳入最新全量,未修改生产评分规则;旧 Docker/PATH 与契约失败日志保留为历史记录,不继续标为当前阻断。
- 前端、受保护文件、历史迁移及底层存储实现未因本批安全修复而修改;本轮服务已停止,测试数据与证据保留,未做 Docker 全局清理。
## 9. 前端接入要点
- 普通 `PUT /profile/me` 不提交非 null `resumeUrl`;简历修改使用受控简历上传/版本流程,不能通过个人资料写入文件引用。
- 请求身份由当前有效令牌解析,不能用客户端手机号、角色字段或用户 ID 代替授权;换账号后须使用新令牌并隔离/清除旧账号缓存。
- 对 400/403/404 按实际接口结果提示,不把权限拒绝降级为展示缓存简历,不通过本地文件名正确推断后端响应头已正确。
- 上传名称传正常文件名即可,后端仅兼容明确的历史/客户端 UTF-8 编码,不要求客户端手写 multipart body,也不允许任意多次 URL 解码。
- 交付的是本地验证规则;前端工程师需待对应版本发布后,在授权的测试账号下复验上传、本人下载、企业授权下载及账号切换。
详细汇总:`D:/Acceptance/qsp-final-20261019/evidence/acceptance-summary.json`。本文未包含账号口令、令牌、真实简历或生产数据库配置。

2
docs/review-moderation-api.md

@ -1,6 +1,6 @@
# 评价审核与防刷基础模块接口说明 # 评价审核与防刷基础模块接口说明
> 职来 (mydreamjob) · 评价审核与防刷基础模块 > 轻松聘 (mydreamjob) · 评价审核与防刷基础模块
> 基础路径:`/api/v1` · 审核管理接口仅 ADMIN;评价公开接口保持原路径 > 基础路径:`/api/v1` · 审核管理接口仅 ADMIN;评价公开接口保持原路径
## 1. 概述 ## 1. 概述

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

866
docs/tools/b01_demo_display_data.py

@ -0,0 +1,866 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""B01 展示字段批次:安全演示数据工具(预检 / 预览 / 导入 / 定向清理 / 核对)。
设计目标(本版重写归属逻辑)
---------------------------
* **先预检、后写入**:任何一次导入在**业务写入之前**先完成全部冲突预检;只要出现
无法证明属本批的冲突(固定手机号被非本批账号占用、同名企业/岗位已被非本批记录占用、
账本丢失/资源丢失/身份变化),立即以**非零退出码**失败,绝不自动认领、覆盖、补角色、
置认证或删除。
* **可靠的批次归属账本**:仅工具使用的辅助表 ``b01_demo_ledger``(**不进入** Flyway 迁移或
应用启动流程)。账本只登记**本工具本次实际创建**的记录,逐条记录:批次 ID、目标库实例
身份、资源类型、**实际创建的主键 ID**、稳定演示条目键(demo_key)、确认资源身份所需的
指纹。**禁止**凭手机号/企业名/岗位标题/描述推断归属。
* **写入与归属登记同事务**:业务记录创建与账本写入在**同一事务**完成,失败整体回滚。
账本表的 DDL(``CREATE TABLE IF NOT EXISTS``)在业务事务**之前**单独执行——MySQL DDL
会隐式提交且不可回滚,故必须与业务事务分开说明与执行。
* **重复导入先校验归属**:账本中资源存在且身份一致 → 跳过(幂等);资源丢失 / ID 指向
异常 / 身份变化 / 账本不完整 → 明确报冲突,不静默接管、不自动修复。
* **清理只用账本精确 ID**:删除前核验资源身份与**依赖**(通过 information_schema 枚举外键
引用方);发现本批之外的依赖、角色或业务关联 → 拒绝清理并给出原因;不级联清除他人数据,
不关闭外键检查。全程在事务内以 ``SELECT ... FOR UPDATE`` 加行锁,避免「检查—删除」竞态。
* **预览与执行共用同一套计划**:默认 dry-run、零写入(**不创建**任何辅助表);导入/清理预览
与执行都基于同一 ``build_plan`` / ``build_cleanup_plan``,执行前针对事务内最新状态**重新
校验**;verify 按账本精确范围统计,不使用 ``LIKE`` 模糊统计;明确区分「没有本批数据」与
「账本缺失,无法证明归属」。
* 保留既有隔离目标限制(回环地址 + 显式白名单三元组 + 线上地址阻断)、显式 ``--apply``、
虚构演示标识;**不伪造**评价、评分、浏览量、真实投递;不关闭任何认证/安全策略。
用法
----
python docs/tools/b01_demo_display_data.py # dry-run 预览(零写入)
python docs/tools/b01_demo_display_data.py --apply # 首次/重复导入
python docs/tools/b01_demo_display_data.py --cleanup # 清理预览(零写入)
python docs/tools/b01_demo_display_data.py --cleanup --apply # 定向清理
python docs/tools/b01_demo_display_data.py --verify # 按账本精确核对
退出码:0=成功;2=参数/目标拒绝;3=归属冲突/无法证明归属而拒绝;1=意外错误。
"""
import argparse
import json
import sys
import pymysql
BATCH_ID = "b01-demo-2026"
DEMO_MARK = "[演示数据·非真实招聘]"
LEDGER_TABLE = "b01_demo_ledger"
# 显式白名单:只有精确匹配的三元组才允许操作。端口 3308 为本批隔离容器固定映射端口。
# 测试隔离库 mydreamjob_b01_demo_test 与演示库同属本批隔离范围,一并显式登记。
ALLOWED_TARGETS = {
("127.0.0.1", 3308, "mydreamjob_b01_demo"),
("127.0.0.1", 3308, "mydreamjob_b01_demo_test"),
}
# 已知线上/生产地址特征:命中即拒绝。
BLOCKED_HOST_MARKERS = (
"prod", "production", "online", "rds.aliyuncs.com", "mydreamjob.com",
"zhilai.com", "aliyuncs", "amazonaws",
)
LOOPBACK = {"127.0.0.1", "localhost", "::1"}
COMPANIES = [
dict(phone="19901000001", name="星河智造科技(演示)", industry="智能制造",
company_size="500-999人", website="https://demo.example/starship",
description=DEMO_MARK + " 虚构智能制造企业,用于展示岗位字段,不涉及真实招聘。"),
dict(phone="19901000002", name="云栖数据服务(演示)", industry="大数据",
company_size="100-499人", website="https://demo.example/cloudnest",
description=DEMO_MARK + " 虚构数据服务企业,用于展示岗位字段,不涉及真实招聘。"),
dict(phone="19901000003", name="沐光文化传媒(演示)", industry="文化传媒",
company_size="20-99人", website="https://demo.example/sunlit",
description=DEMO_MARK + " 虚构文化传媒企业,用于展示岗位字段,不涉及真实招聘。"),
]
JOBS = [
# 星河智造科技(演示)
dict(phone="19901000001", title="工业机器人算法实习生", city="上海", employment_type="INTERNSHIP",
salary_min=4000, salary_max=6000, experience="应届生", education="本科",
benefits=["导师带教", "免费班车", "实习转正"], salary_months=12, work_location="上海市浦东新区",
description=DEMO_MARK + " 参与机器人运动控制算法开发。", requirements="自动化/计算机相关专业。"),
dict(phone="19901000001", title="嵌入式软件工程师", city="上海", employment_type="FULL_TIME",
salary_min=20000, salary_max=35000, experience="3-5年", education="本科",
benefits=["五险一金", "弹性工作", "年度体检", "股票期权"], salary_months=14,
work_location="上海市闵行区", description=DEMO_MARK + " 负责机器人控制器嵌入式软件开发。",
requirements="熟悉 C/C++ 与 RTOS。"),
dict(phone="19901000001", title="高级机械结构工程师", city="苏州", employment_type="FULL_TIME",
salary_min=30000, salary_max=50000, experience="5-10年", education="硕士",
benefits=["五险一金", "补充医疗", "带薪年假"], salary_months=15, work_location="江苏省苏州市工业园区",
description=DEMO_MARK + " 负责精密机械结构设计与验证。", requirements="机械工程硕士,5 年以上经验。"),
dict(phone="19901000001", title="供应链计划专员", city="上海", employment_type="FULL_TIME",
salary_min=12000, salary_max=18000, experience="1-3年", education="大专",
benefits=["五险一金", "餐补", "节日福利"], salary_months=13, work_location="上海市嘉定区",
description=DEMO_MARK + " 负责物料计划与库存管理。", requirements="供应链或物流相关背景。"),
# 云栖数据服务(演示)
dict(phone="19901000002", title="数据平台后端工程师", city="杭州", employment_type="FULL_TIME",
salary_min=25000, salary_max=45000, experience="3-5年", education="本科",
benefits=["五险一金", "弹性工作", "免费三餐", "技术分享"], salary_months=16,
work_location="浙江省杭州市余杭区", description=DEMO_MARK + " 负责数据平台服务端研发。",
requirements="扎实的 Java 与分布式基础。"),
dict(phone="19901000002", title="大数据开发工程师", city="杭州", employment_type="FULL_TIME",
salary_min=22000, salary_max=40000, experience="3-5年", education="本科",
benefits=["五险一金", "年度体检", "带薪年假"], salary_months=14, work_location="浙江省杭州市滨江区",
description=DEMO_MARK + " 负责离线与实时数仓开发。", requirements="熟悉 Spark/Flink。"),
dict(phone="19901000002", title="数据分析师(校招)", city="杭州", employment_type="FULL_TIME",
salary_min=15000, salary_max=22000, experience="应届生", education="本科",
benefits=["五险一金", "租房补贴", "培训体系"], salary_months=13, work_location="浙江省杭州市余杭区",
description=DEMO_MARK + " 负责业务数据分析与报表建设。", requirements="统计学/数学相关专业。"),
dict(phone="19901000002", title="SRE 运维工程师", city="远程", employment_type="FULL_TIME",
salary_min=28000, salary_max=48000, experience="5-10年", education="不限",
benefits=["五险一金", "远程办公", "弹性工作", "年度体检"], salary_months=15, work_location="全国远程",
description=DEMO_MARK + " 负责平台稳定性与可观测性建设。", requirements="熟悉 Kubernetes 与监控体系。"),
# 沐光文化传媒(演示)
dict(phone="19901000003", title="短视频内容运营", city="成都", employment_type="FULL_TIME",
salary_min=8000, salary_max=13000, experience="1-3年", education="大专",
benefits=["五险一金", "下午茶", "节日福利"], salary_months=12, work_location="四川省成都市武侯区",
description=DEMO_MARK + " 负责短视频账号内容策划与运营。", requirements="有内容运营经验优先。"),
dict(phone="19901000003", title="视觉设计师", city="成都", employment_type="FULL_TIME",
salary_min=12000, salary_max=20000, experience="3-5年", education="本科",
benefits=["五险一金", "弹性工作", "作品集补贴"], salary_months=13, work_location="四川省成都市高新区",
description=DEMO_MARK + " 负责品牌视觉与活动设计。", requirements="设计相关专业,作品集优秀。"),
dict(phone="19901000003", title="市场活动策划", city="成都", employment_type="PART_TIME",
salary_min=6000, salary_max=9000, experience="1-3年", education="不限",
benefits=["五险一金", "项目奖金"], salary_months=12, work_location="四川省成都市锦江区",
description=DEMO_MARK + " 负责线下市场活动策划与执行。", requirements="具备活动执行经验。"),
dict(phone="19901000003", title="客户成功经理", city="重庆", employment_type="FULL_TIME",
salary_min=15000, salary_max=25000, experience="3-5年", education="本科",
benefits=["五险一金", "补充医疗", "带薪年假", "团队旅游"], salary_months=14,
work_location="重庆市渝北区", description=DEMO_MARK + " 负责重点客户关系维护与续约。",
requirements="具备 B 端客户服务经验。"),
]
# 稳定演示条目键与身份指纹:账本据此判定「本批资源身份」是否与当前库内记录一致。
def user_demo_key(phone):
return "user:" + phone
def company_demo_key(phone):
return "company:" + phone
def job_demo_key(phone, title):
return "job:%s|%s" % (phone, title)
def user_fingerprint(phone):
return "phone=%s" % phone
def company_fingerprint(phone, name):
return "owner_phone=%s;name=%s" % (phone, name)
def job_fingerprint(phone, title):
return "company_phone=%s;title=%s" % (phone, title)
LEDGER_DDL = """
CREATE TABLE IF NOT EXISTS %s (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
batch_id VARCHAR(64) NOT NULL,
target_identity VARCHAR(160) NOT NULL,
resource_type VARCHAR(16) NOT NULL,
resource_id BIGINT NOT NULL,
demo_key VARCHAR(191) NOT NULL,
identity_fingerprint VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_ledger_target_key (target_identity, demo_key),
UNIQUE KEY uk_ledger_target_res (target_identity, resource_type, resource_id),
KEY idx_ledger_batch (batch_id, target_identity)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
""" % LEDGER_TABLE
# ── 连接与目标限制 ──────────────────────────────────────────────────
def assert_safe_target(host, port, database):
"""显式白名单 + 线上地址阻断。任何不满足都直接终止,不降级。"""
for marker in BLOCKED_HOST_MARKERS:
if marker in host.lower() or marker in database.lower():
raise SystemExit(f"[REFUSED] 目标疑似线上地址,已拒绝:host={host} database={database}")
if host not in LOOPBACK:
raise SystemExit(f"[REFUSED] 目标 host 非回环地址:{host}")
if (host, port, database) not in ALLOWED_TARGETS:
raise SystemExit(
f"[REFUSED] 目标不在显式白名单内:{(host, port, database)};"
f"允许:{sorted(ALLOWED_TARGETS)}")
if "b01" not in database:
raise SystemExit(f"[REFUSED] 数据库名缺少本批标识 b01:{database}")
def connect(args):
assert_safe_target(args.host, args.port, args.database)
return pymysql.connect(host=args.host, port=args.port, user=args.user,
password=args.password, database=args.database,
charset="utf8mb4", autocommit=False)
# ── 查询助手 ────────────────────────────────────────────────────────
def query(cur, sql, params=None):
cur.execute(sql, params if params else None)
cols = [d[0] for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
def query_one(cur, sql, params=None):
rows = query(cur, sql, params)
return rows[0] if rows else None
def scalar(cur, sql, params=None):
row = query_one(cur, sql, params)
if not row:
return None
return list(row.values())[0]
def table_exists(cur, name):
n = scalar(cur, "SELECT COUNT(*) FROM information_schema.tables "
"WHERE table_schema=DATABASE() AND table_name=%s", (name,))
return int(n or 0) > 0
REQUIRED_BUSINESS_TABLES = ("users", "user_roles", "companies", "jobs")
def assert_business_schema(cur):
missing = [t for t in REQUIRED_BUSINESS_TABLES if not table_exists(cur, t)]
if missing:
raise SystemExit("[REFUSED] 目标库缺少必需业务表 %s,拒绝操作" % missing)
def target_identity(cur, args):
"""目标库实例身份:优先 server_uuid(可区分不同实例),退化到 host:port。"""
uuid = None
try:
uuid = scalar(cur, "SELECT @@server_uuid")
except Exception:
uuid = None
if not uuid:
uuid = "%s:%s" % (args.host, args.port)
return "%s/%s" % (uuid, args.database)
# ── 账本读写 ────────────────────────────────────────────────────────
def read_ledger(cur, identity, for_update=False):
"""返回本批本目标的账本行;账本表不存在返回 None(区别于空列表)。"""
if not table_exists(cur, LEDGER_TABLE):
return None
sql = ("SELECT id, resource_type, resource_id, demo_key, identity_fingerprint "
"FROM %s WHERE batch_id=%%s AND target_identity=%%s" % LEDGER_TABLE)
if for_update:
sql += " FOR UPDATE"
rows = query(cur, sql, (BATCH_ID, identity))
for r in rows:
r["resource_id"] = int(r["resource_id"])
return rows
def insert_ledger(cur, identity, resource_type, resource_id, demo_key, fingerprint):
cur.execute(
"INSERT INTO %s(batch_id, target_identity, resource_type, resource_id, demo_key, "
"identity_fingerprint) VALUES(%%s,%%s,%%s,%%s,%%s,%%s)" % LEDGER_TABLE,
(BATCH_ID, identity, resource_type, resource_id, demo_key, fingerprint))
# ── 计划构建(预检) ────────────────────────────────────────────────
def validate_ledger_shape(ledger):
"""账本键、类型和指纹必须属于当前定义;不能用损坏的账本证明归属。"""
expected = {}
for c in COMPANIES:
expected[user_demo_key(c['phone'])] = ('USER', user_fingerprint(c['phone']))
expected[company_demo_key(c['phone'])] = ('COMPANY', company_fingerprint(c['phone'], c['name']))
for j in JOBS:
expected[job_demo_key(j['phone'], j['title'])] = ('JOB', job_fingerprint(j['phone'], j['title']))
conflicts = []
seen = set()
for row in ledger or []:
key = row['demo_key']
if (key in seen or expected.get(key) != (row['resource_type'], row['identity_fingerprint'])
or row['resource_id'] <= 0):
conflicts.append('账本键/类型/指纹/ID 异常:%s,拒绝操作' % key)
seen.add(key)
return conflicts
def build_plan(cur, identity, ledger):
"""只读地计算导入计划。返回 (actions, conflicts)。
actions 中每条:kind = create_*/keep_*;携带 type/demo_key/label 与已有 id(如有)。
conflicts 非空表示无法证明本批归属,调用方必须拒绝写入。
"""
actions, conflicts = [], validate_ledger_shape(ledger)
if conflicts:
return actions, conflicts
ledger_by_key = {r["demo_key"]: r for r in (ledger or [])}
used_keys = set()
user_ids, company_ids = {}, {}
# 用户
for c in COMPANIES:
phone = c["phone"]
key = user_demo_key(phone)
used_keys.add(key)
existing = query_one(cur, "SELECT id FROM users WHERE phone=%s", (phone,))
lr = ledger_by_key.get(key)
if existing is None:
if lr is not None:
conflicts.append("账本记录本批用户 %s(id=%s) 已丢失:拒绝静默重建或接管"
% (phone, lr["resource_id"]))
user_ids[phone] = None
else:
actions.append(dict(kind="create_user", type="USER", demo_key=key,
phone=phone, label=phone, id=None))
user_ids[phone] = None
continue
if lr is None:
conflicts.append("手机号 %s 已被非本批账号占用(users.id=%s)且账本无本批归属记录:"
"拒绝认领/覆盖/补角色" % (phone, existing["id"]))
user_ids[phone] = existing["id"]
continue
if int(lr["resource_id"]) != int(existing["id"]) \
or lr["identity_fingerprint"] != user_fingerprint(phone):
conflicts.append("手机号 %s 账本与现有账号不一致(账本 id=%s vs 实际 id=%s):拒绝接管"
% (phone, lr["resource_id"], existing["id"]))
user_ids[phone] = existing["id"]
continue
actions.append(dict(kind="keep_user", type="USER", demo_key=key, phone=phone,
label=phone, id=existing["id"]))
user_ids[phone] = existing["id"]
# 企业
for c in COMPANIES:
phone, name = c["phone"], c["name"]
key = company_demo_key(phone)
used_keys.add(key)
uid = user_ids.get(phone)
label = "%s / %s" % (phone, name)
lr = ledger_by_key.get(key)
by_owner = (query_one(cur, "SELECT id, name FROM companies WHERE owner_user_id=%s", (uid,))
if uid is not None else None)
by_name = query_one(cur, "SELECT id, owner_user_id FROM companies WHERE name=%s", (name,))
if uid is None:
# 演示账号即将新建 → 企业必为新建;但必须确保没有同名企业被他方占用
if lr is not None:
conflicts.append("账本记录本批企业 %s(id=%s) 已丢失:拒绝静默重建"
% (name, lr["resource_id"]))
company_ids[phone] = None
elif by_name is not None:
conflicts.append("企业名 %s 已被非本批企业占用(companies.id=%s, owner_user_id=%s):"
"拒绝覆盖/认领" % (name, by_name["id"], by_name["owner_user_id"]))
company_ids[phone] = None
else:
actions.append(dict(kind="create_company", type="COMPANY", demo_key=key,
phone=phone, name=name, label=label, id=None))
company_ids[phone] = None
continue
if by_owner is None:
if lr is not None:
conflicts.append("账本记录本批企业 %s(id=%s, owner=%s) 已丢失:拒绝静默重建"
% (name, lr["resource_id"], phone))
company_ids[phone] = None
continue
if by_name is not None:
conflicts.append("企业名 %s 已被非本批企业占用(companies.id=%s, owner_user_id=%s):"
"拒绝覆盖/认领" % (name, by_name["id"], by_name["owner_user_id"]))
company_ids[phone] = None
continue
actions.append(dict(kind="create_company", type="COMPANY", demo_key=key,
phone=phone, name=name, label=label, id=None))
company_ids[phone] = None
continue
# 演示账号名下已有企业:必须能由账本证明归属,且不能另有同名企业
ok = (lr is not None and int(lr["resource_id"]) == int(by_owner["id"])
and lr["identity_fingerprint"] == company_fingerprint(phone, by_owner["name"]))
if not ok:
conflicts.append("演示账号 %s 名下已有企业(companies.id=%s)但无本批账本归属或身份不一致:"
"拒绝覆盖企业资料/置认证" % (phone, by_owner["id"]))
company_ids[phone] = by_owner["id"]
continue
if by_name is not None and int(by_name["id"]) != int(by_owner["id"]):
conflicts.append("企业名 %s 存在同名重复记录(companies.id=%s 非本批):拒绝继续"
% (name, by_name["id"]))
company_ids[phone] = by_owner["id"]
continue
actions.append(dict(kind="keep_company", type="COMPANY", demo_key=key, phone=phone,
name=name, label=label, id=by_owner["id"]))
company_ids[phone] = by_owner["id"]
# 岗位
for j in JOBS:
phone, title = j["phone"], j["title"]
key = job_demo_key(phone, title)
used_keys.add(key)
cid = company_ids.get(phone)
label = "%s / %s" % (phone, title)
if cid is None:
lr = ledger_by_key.get(key)
if lr is not None:
conflicts.append("账本记录本批岗位 %s(id=%s) 已丢失:拒绝静默重建"
% (label, lr["resource_id"]))
else:
actions.append(dict(kind="create_job", type="JOB", demo_key=key, phone=phone,
title=title, label=label, id=None))
continue
existing = query_one(cur, "SELECT id FROM jobs WHERE company_id=%s AND title=%s",
(cid, title))
lr = ledger_by_key.get(key)
if existing is None:
if lr is not None:
conflicts.append("账本记录本批岗位 %s(id=%s) 已丢失:拒绝静默重建"
% (label, lr["resource_id"]))
else:
actions.append(dict(kind="create_job", type="JOB", demo_key=key, phone=phone,
title=title, label=label, id=None))
continue
ok = (lr is not None and int(lr["resource_id"]) == int(existing["id"])
and lr["identity_fingerprint"] == job_fingerprint(phone, title))
if not ok:
conflicts.append("本批企业 %s 下已存在同名岗位 '%s'(jobs.id=%s)但无本批账本归属:"
"拒绝认领/覆盖" % (phone, title, existing["id"]))
continue
actions.append(dict(kind="keep_job", type="JOB", demo_key=key, phone=phone,
title=title, label=label, id=existing["id"]))
# 账本中存在当前批次定义之外的记录 → 拒绝(不自动处理、不猜测归属)
for r in (ledger or []):
if r["demo_key"] not in used_keys:
conflicts.append("账本含当前批次定义之外的记录(%s/%s id=%s):拒绝自动处理"
% (r["resource_type"], r["demo_key"], r["resource_id"]))
return actions, conflicts
# ── 导入执行 ────────────────────────────────────────────────────────
def _benefits_json(benefits):
return json.dumps(benefits, ensure_ascii=False, separators=(",", ":"))
def apply_import(conn, cur, identity):
"""执行导入:先预检(零写入),无冲突后再建账本表 + 业务写入/账本登记同事务。
返回 (created, conflicts)。conflicts 非空时不写入任何业务数据、也不创建账本表。
"""
# 1) 预检:只读,不创建任何表、不写任何行。
ledger = read_ledger(cur, identity)
_, conflicts = build_plan(cur, identity, ledger)
if conflicts:
conn.rollback()
return None, conflicts
# 2) 账本表 DDL(MySQL DDL 隐式提交、不可回滚,故独立于业务事务)。
cur.execute(LEDGER_DDL)
conn.commit()
# 3) 业务事务:针对最新状态重新校验后写入。
try:
ledger = read_ledger(cur, identity)
actions, conflicts = build_plan(cur, identity, ledger)
if conflicts:
conn.rollback()
return None, conflicts
comp_by_phone = {c["phone"]: c for c in COMPANIES}
job_by_key = {job_demo_key(j["phone"], j["title"]): j for j in JOBS}
created = dict(users=0, companies=0, jobs=0)
user_ids, company_ids = {}, {}
for a in actions:
if a["kind"] == "keep_user":
user_ids[a["phone"]] = a["id"]
elif a["kind"] == "create_user":
cur.execute("INSERT INTO users(phone, nickname, role, is_active, is_verified) "
"VALUES(%s,%s,'COMPANY',1,1)",
(a["phone"], a["phone"] + "(演示账号)"))
uid = cur.lastrowid
cur.execute("INSERT INTO user_roles(user_id, role) VALUES(%s,'COMPANY')", (uid,))
insert_ledger(cur, identity, "USER", uid, a["demo_key"], user_fingerprint(a["phone"]))
user_ids[a["phone"]] = uid
created["users"] += 1
elif a["kind"] == "keep_company":
company_ids[a["phone"]] = a["id"]
elif a["kind"] == "create_company":
src = comp_by_phone[a["phone"]]
uid = user_ids[a["phone"]]
cur.execute("INSERT INTO companies(owner_user_id, name, logo_url, description, "
"industry, company_size, website, address, verified) "
"VALUES(%s,%s,NULL,%s,%s,%s,%s,NULL,1)",
(uid, src["name"], src["description"], src["industry"],
src["company_size"], src["website"]))
cid = cur.lastrowid
company_ids[a["phone"]] = cid
insert_ledger(cur, identity, "COMPANY", cid, a["demo_key"],
company_fingerprint(a["phone"], src["name"]))
created["companies"] += 1
elif a["kind"] == "keep_job":
continue
elif a["kind"] == "create_job":
j = job_by_key[a["demo_key"]]
cid = company_ids[a["phone"]]
cur.execute("INSERT INTO jobs(company_id, title, description, requirements, city, "
"employment_type, salary_min, salary_max, experience, education, "
"benefits_json, salary_months, work_location, status, published_at) "
"VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'PUBLISHED',NOW())",
(cid, j["title"], j["description"], j["requirements"], j["city"],
j["employment_type"], j["salary_min"], j["salary_max"], j["experience"],
j["education"], _benefits_json(j["benefits"]), j["salary_months"],
j["work_location"]))
jid = cur.lastrowid
insert_ledger(cur, identity, "JOB", jid, a["demo_key"],
job_fingerprint(a["phone"], j["title"]))
created["jobs"] += 1
conn.commit()
return created, []
except Exception:
conn.rollback()
raise
# ── 清理计划与执行 ──────────────────────────────────────────────────
def _dependent_columns(cur, table):
return query(cur,
"SELECT TABLE_NAME AS t, COLUMN_NAME AS c FROM information_schema.KEY_COLUMN_USAGE "
"WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=%s "
"AND REFERENCED_COLUMN_NAME='id' ORDER BY TABLE_NAME", (table,))
def _count_refs(cur, table, column, rid):
return int(scalar(cur, "SELECT COUNT(*) FROM `%s` WHERE `%s`=%%s" % (table, column), (rid,)) or 0)
def build_cleanup_plan(cur, identity, ledger, for_update=False):
"""只读(或加锁)地计算清理计划。返回 (plan, conflicts)。
ledger 为 None → 账本缺失,无法证明归属。ledger 为空 → 无本批数据。
"""
if ledger is None:
return None, ["账本表不存在:无法证明本批归属,拒绝清理"]
plan = dict(jobs=[], companies=[], users=[], ledger_ids=[])
conflicts = []
if not ledger:
return plan, conflicts # 已清理或无本批数据 → 安全无操作
conflicts.extend(validate_ledger_shape(ledger))
if conflicts:
return plan, conflicts
ledger_by_key = {r['demo_key']: r for r in ledger}
job_rows = [r for r in ledger if r["resource_type"] == "JOB"]
comp_rows = [r for r in ledger if r["resource_type"] == "COMPANY"]
user_rows = [r for r in ledger if r["resource_type"] == "USER"]
ledger_company_ids = {r["resource_id"] for r in comp_rows}
ledger_job_ids = {r["resource_id"] for r in job_rows}
# 账本完整性:COMPANY 必须有对应 USER,JOB 必须有对应 COMPANY
for r in comp_rows:
owner_phone = r["demo_key"].split(":", 1)[1]
if not any(u["demo_key"] == user_demo_key(owner_phone) for u in user_rows):
conflicts.append("账本不完整:企业 %s 缺少归属用户账本记录" % r["demo_key"])
for r in job_rows:
company_key = r["demo_key"].split("|", 1)[0].replace("job:", "company:")
if not any(c["demo_key"] == company_key for c in comp_rows):
conflicts.append("账本不完整:岗位 %s 缺少归属企业账本记录" % r["demo_key"])
# 用户
for r in user_rows:
uid = r["resource_id"]
phone = r["demo_key"].split(":", 1)[1]
row = query_one(cur, "SELECT id, phone FROM users WHERE id=%s" + (" FOR UPDATE" if for_update else ""), (uid,))
if row is None:
conflicts.append("账本记录用户 %s(id=%s) 已丢失:无法确认清理范围,拒绝" % (phone, uid))
continue
if row['phone'] != phone or r["identity_fingerprint"] != user_fingerprint(row['phone']):
conflicts.append("账本用户 %s(id=%s) 身份指纹异常:拒绝清理" % (phone, uid))
continue
# 允许的依赖:名下本批企业 + 仅 COMPANY 角色;其余依赖一律拒绝
for dep in _dependent_columns(cur, "users"):
t, c = dep["t"], dep["c"]
n = _count_refs(cur, t, c, uid)
if n == 0:
continue
if t == "companies" and c == "owner_user_id":
owned = [int(x["id"]) for x in query(
cur, "SELECT id FROM companies WHERE owner_user_id=%s", (uid,))]
extra = [cid for cid in owned if cid not in ledger_company_ids]
if extra:
conflicts.append("用户 %s 名下存在非本批企业 %s:拒绝清理" % (phone, extra))
elif t == "user_roles" and c == "user_id":
roles = [x["role"] for x in query(
cur, "SELECT role FROM user_roles WHERE user_id=%s", (uid,))]
extra = [x for x in roles if x != "COMPANY"]
if extra:
conflicts.append("用户 %s 存在非本批角色 %s:拒绝清理" % (phone, extra))
else:
conflicts.append("用户 %s 存在本批之外的依赖 %s.%s(%d 行):拒绝清理" % (phone, t, c, n))
plan["users"].append(dict(id=uid, phone=phone, demo_key=r["demo_key"]))
# 企业
for r in comp_rows:
cid = r["resource_id"]
phone = r["demo_key"].split(":", 1)[1]
suffix = " FOR UPDATE" if for_update else ""
row = query_one(cur, "SELECT c.id AS id, c.name AS name, c.owner_user_id, u.phone AS owner_phone "
"FROM companies c JOIN users u ON u.id=c.owner_user_id "
"WHERE c.id=%s" + suffix, (cid,))
if row is None:
conflicts.append("账本记录企业 %s(id=%s) 已丢失:无法确认清理范围,拒绝" % (phone, cid))
continue
parent = ledger_by_key.get(user_demo_key(phone))
if (parent is None or row['owner_user_id'] != parent['resource_id']
or row['owner_phone'] != phone
or r["identity_fingerprint"] != company_fingerprint(row["owner_phone"], row["name"])):
conflicts.append("账本企业 %s(id=%s) 身份指纹异常:拒绝清理" % (phone, cid))
continue
for dep in _dependent_columns(cur, "companies"):
t, c = dep["t"], dep["c"]
n = _count_refs(cur, t, c, cid)
if n == 0:
continue
if t == "jobs" and c == "company_id":
jobs_here = [int(x["id"]) for x in query(
cur, "SELECT id FROM jobs WHERE company_id=%s", (cid,))]
extra = [jid for jid in jobs_here if jid not in ledger_job_ids]
if extra:
conflicts.append("企业 %s 下存在非本批岗位 %s:拒绝清理" % (phone, extra))
else:
conflicts.append("企业 %s 存在本批之外的依赖 %s.%s(%d 行):拒绝清理" % (phone, t, c, n))
plan["companies"].append(dict(id=cid, phone=phone, demo_key=r["demo_key"]))
# 岗位
for r in job_rows:
jid = r["resource_id"]
suffix = " FOR UPDATE" if for_update else ""
row = query_one(cur, "SELECT j.id AS id, j.title AS title, j.company_id, u.phone AS owner_phone "
"FROM jobs j JOIN companies c ON c.id=j.company_id "
"JOIN users u ON u.id=c.owner_user_id WHERE j.id=%s" + suffix, (jid,))
if row is None:
conflicts.append("账本记录岗位 id=%s 已丢失:无法确认清理范围,拒绝" % jid)
continue
parent_key = r['demo_key'].split('|', 1)[0].replace('job:', 'company:', 1)
parent = ledger_by_key.get(parent_key)
if (parent is None or row['company_id'] != parent['resource_id']
or r["identity_fingerprint"] != job_fingerprint(row["owner_phone"], row["title"])):
conflicts.append("账本岗位 id=%s 身份指纹异常:拒绝清理" % jid)
continue
for dep in _dependent_columns(cur, "jobs"):
t, c = dep["t"], dep["c"]
n = _count_refs(cur, t, c, jid)
if n > 0:
conflicts.append("岗位 id=%s 存在本批之外的依赖 %s.%s(%d 行):拒绝清理" % (jid, t, c, n))
plan["jobs"].append(dict(id=jid, company_id=row['company_id'], demo_key=r["demo_key"]))
plan["ledger_ids"] = [r["id"] for r in ledger]
return plan, conflicts
def apply_cleanup(conn, cur, identity):
"""执行清理:事务 + 行锁,只删除账本精确 ID。返回 (removed, conflicts)。"""
try:
ledger = read_ledger(cur, identity, for_update=True)
plan, conflicts = build_cleanup_plan(cur, identity, ledger, for_update=True)
if conflicts:
conn.rollback()
return None, conflicts
removed = dict(jobs=0, companies=0, users=0, ledger=0)
for job in plan["jobs"]:
cur.execute("DELETE FROM jobs WHERE id=%s AND company_id=%s", (job["id"], job['company_id']))
if cur.rowcount != 1:
raise RuntimeError('岗位删除目标变化,回滚全部清理')
removed["jobs"] += cur.rowcount
for comp in plan["companies"]:
cur.execute("DELETE FROM companies WHERE id=%s AND owner_user_id="
"(SELECT id FROM users WHERE phone=%s)", (comp["id"], comp["phone"]))
if cur.rowcount != 1:
raise RuntimeError('企业删除目标变化,回滚全部清理')
removed["companies"] += cur.rowcount
for user in plan["users"]:
cur.execute("DELETE FROM user_roles WHERE user_id=%s", (user["id"],))
cur.execute("DELETE FROM users WHERE id=%s AND phone=%s", (user["id"], user['phone']))
if cur.rowcount != 1:
raise RuntimeError('用户删除目标变化,回滚全部清理')
removed["users"] += cur.rowcount
if plan["ledger_ids"]:
fmt = ",".join(["%s"] * len(plan["ledger_ids"]))
cur.execute("DELETE FROM %s WHERE id IN (%s)" % (LEDGER_TABLE, fmt),
tuple(plan["ledger_ids"]))
removed["ledger"] = cur.rowcount
if cur.rowcount != len(plan['ledger_ids']):
raise RuntimeError('账本删除范围变化,回滚全部清理')
conn.commit()
return removed, []
except Exception:
conn.rollback()
raise
# ── 核对 ────────────────────────────────────────────────────────────
def verify(cur, identity):
"""按账本精确范围核对(不使用 LIKE 模糊统计)。"""
ledger = read_ledger(cur, identity)
demo_phones = [c["phone"] for c in COMPANIES]
demo_names = [c["name"] for c in COMPANIES]
demo_titles = [(j["phone"], j["title"]) for j in JOBS]
def present_demo_users():
fmt = ",".join(["%s"] * len(demo_phones))
return [r["id"] for r in query(
cur, "SELECT id FROM users WHERE phone IN (%s)" % fmt, tuple(demo_phones))]
def present_demo_companies():
fmt = ",".join(["%s"] * len(demo_names))
return [r["id"] for r in query(
cur, "SELECT id FROM companies WHERE name IN (%s)" % fmt, tuple(demo_names))]
def present_demo_jobs():
ids = []
for phone, title in demo_titles:
owner = query_one(cur, "SELECT id FROM users WHERE phone=%s", (phone,))
if not owner:
continue
comp = query_one(cur, "SELECT id FROM companies WHERE owner_user_id=%s", (owner["id"],))
if not comp:
continue
job = query_one(cur, "SELECT id FROM jobs WHERE company_id=%s AND title=%s",
(comp["id"], title))
if job:
ids.append(job["id"])
return ids
if ledger is None:
return dict(status="LEDGER_MISSING",
detail="账本表不存在:无法证明本批归属;不得据此清理",
demo_users_present=present_demo_users(),
demo_companies_present=present_demo_companies(),
demo_jobs_present=present_demo_jobs())
counts = dict(USER=0, COMPANY=0, JOB=0)
for r in ledger:
counts[r["resource_type"]] = counts.get(r["resource_type"], 0) + 1
alive = dict(USER=0, COMPANY=0, JOB=0)
for r in ledger:
if r["resource_type"] == "USER":
row = query_one(cur, "SELECT id FROM users WHERE id=%s", (r["resource_id"],))
elif r["resource_type"] == "COMPANY":
row = query_one(cur, "SELECT id FROM companies WHERE id=%s", (r["resource_id"],))
else:
row = query_one(cur, "SELECT id FROM jobs WHERE id=%s", (r["resource_id"],))
if row is not None:
alive[r["resource_type"]] += 1
if not ledger:
status = "NO_BATCH_DATA"
detail = "账本无本批记录"
else:
status = "HAS_BATCH_DATA"
detail = "按账本精确范围统计"
return dict(status=status, detail=detail, ledger_counts=counts, ledger_alive=alive,
demo_users_present=present_demo_users(),
demo_companies_present=present_demo_companies(),
demo_jobs_present=present_demo_jobs())
# ── 计划打印 ────────────────────────────────────────────────────────
def print_plan(actions, counters):
print("[%s] 导入预览(dry-run,未写入,共 %d 项动作):" % (BATCH_ID, len(actions)))
for a in actions:
print(" %-14s %s" % (a["kind"], a["label"]))
print(" 计划新建:", counters)
def print_cleanup_plan(plan):
print("[%s] 清理预览(dry-run,未写入):" % BATCH_ID)
print(" 将删除岗位:", [j["id"] for j in plan["jobs"]])
print(" 将删除企业:", [c["id"] for c in plan["companies"]])
print(" 将删除用户:", [u["id"] for u in plan["users"]])
print(" 将删除账本行:", len(plan["ledger_ids"]))
# ── CLI ─────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="B01 展示字段批次演示数据工具(默认 dry-run)")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=3308)
parser.add_argument("--database", default="mydreamjob_b01_demo")
parser.add_argument("--user", default="b01demo")
parser.add_argument("--password", default="b01-demo-only")
parser.add_argument("--apply", action="store_true", help="显式确认执行写入")
parser.add_argument("--cleanup", action="store_true", help="执行定向清理(需配合 --apply)")
parser.add_argument("--verify", action="store_true", help="按账本精确核对")
args = parser.parse_args()
conn = connect(args)
try:
with conn.cursor() as cur:
assert_business_schema(cur)
identity = target_identity(cur, args)
if args.verify:
print("[%s] verify identity=%s:" % (BATCH_ID, identity),
json.dumps(verify(cur, identity), ensure_ascii=False))
return 0
if args.cleanup:
ledger = read_ledger(cur, identity, for_update=args.apply)
plan, conflicts = build_cleanup_plan(cur, identity, ledger, for_update=args.apply)
if conflicts:
print("[%s] 清理拒绝:" % BATCH_ID)
for c in conflicts:
print(" -", c)
conn.rollback()
return 3
if not args.apply:
print_cleanup_plan(plan)
conn.rollback()
return 0
removed, conflicts = apply_cleanup(conn, cur, identity)
if conflicts:
print("[%s] 清理拒绝:" % BATCH_ID)
for c in conflicts:
print(" -", c)
return 3
print("[%s] cleanup applied:" % BATCH_ID, removed)
return 0
ledger = read_ledger(cur, identity)
actions, conflicts = build_plan(cur, identity, ledger)
if conflicts:
print("[%s] 导入预检失败,已拒绝任何写入:" % BATCH_ID)
for c in conflicts:
print(" -", c)
conn.rollback()
return 3
if not args.apply:
counters = dict(
users=sum(1 for a in actions if a["kind"] == "create_user"),
companies=sum(1 for a in actions if a["kind"] == "create_company"),
jobs=sum(1 for a in actions if a["kind"] == "create_job"))
print_plan(actions, counters)
conn.rollback()
return 0
# 执行前针对事务内最新状态重新校验(apply_import 内部重新 build_plan)
created, conflicts = apply_import(conn, cur, identity)
if conflicts:
print("[%s] 导入预检失败,已拒绝任何写入:" % BATCH_ID)
for c in conflicts:
print(" -", c)
return 3
print("[%s] apply 完成,新建:" % BATCH_ID, created,
"核对:", json.dumps(verify(cur, identity), ensure_ascii=False))
return 0
finally:
conn.close()
if __name__ == "__main__":
sys.exit(main())

594
docs/tools/b01_demo_display_data_test.py

@ -0,0 +1,594 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""B01 演示数据工具的**归属安全**测试(隔离库 + 合成数据 + 哨兵数据)。
自包含:启动**仅本测试使用**的 MySQL 容器(b01-demo-test-mysql),建库
``mydreamjob_b01_demo_test``、合成业务表结构 + 哨兵数据,逐项验证工具的归属安全。
结束时只销毁本测试容器(不触碰其他容器/数据卷)。
不能只用全空库证明安全:冲突与哨兵数据是本项验收重点。
用法:python docs/tools/b01_demo_display_data_test.py
退出码:0=全部通过;1=存在失败;2=环境不可用。
"""
import argparse
import json
import os
import subprocess
import sys
import time
import pymysql
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import b01_demo_display_data as tool # noqa: E402
CONTAINER = "qsp-final-demo-1019"
IMAGE = "mysql:8.4"
HOST = "127.0.0.1"
PORT = 3308
DB = "mydreamjob_b01_demo_test"
APP_USER = "b01demo"
APP_PASS = "b01-demo-only"
ROOT_PASS = "b01-root-test"
DEMO_PHONES = [c["phone"] for c in tool.COMPANIES]
DEMO_NAMES = [c["name"] for c in tool.COMPANIES]
DEMO_TITLES = [(j["phone"], j["title"]) for j in tool.JOBS]
RESULTS = []
OWN_CONTAINER_ID = None
def check(case, ok, detail=""):
RESULTS.append(dict(case=case, ok=bool(ok), detail=str(detail)[:500]))
print("[%s] %-60s %s" % ("PASS" if ok else "FAIL", case, str(detail)[:120]))
return bool(ok)
# ── Docker / 连接 ───────────────────────────────────────────────────
def _run(cmd):
return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8",
errors="replace")
def start_container():
global OWN_CONTAINER_ID
if _run(["docker", "inspect", CONTAINER]).returncode == 0:
raise RuntimeError("同名容器存在,拒绝删除或接管:" + CONTAINER)
r = _run(["docker", "run", "-d", "--name", CONTAINER,
"-e", "MYSQL_ROOT_PASSWORD=" + ROOT_PASS,
"-e", "MYSQL_ROOT_HOST=%",
"-e", "MYSQL_DATABASE=" + DB,
"-p", "127.0.0.1:%d:3306" % PORT, IMAGE,
"--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"])
if r.returncode != 0:
raise RuntimeError("docker run 失败: " + (r.stderr or "")[:400])
OWN_CONTAINER_ID = r.stdout.strip()
for _ in range(90):
try:
c = root_conn()
c.close()
return
except Exception:
time.sleep(2)
raise RuntimeError("MySQL 容器未就绪")
def stop_container():
if OWN_CONTAINER_ID:
_run(["docker", "rm", "-f", OWN_CONTAINER_ID])
def root_conn(db=DB, autocommit=True):
return pymysql.connect(host=HOST, port=PORT, user="root", password=ROOT_PASS,
database=db, charset="utf8mb4", autocommit=autocommit)
def app_conn():
return pymysql.connect(host=HOST, port=PORT, user=APP_USER, password=APP_PASS,
database=DB, charset="utf8mb4", autocommit=False)
class Args:
def __init__(self):
self.host, self.port, self.database = HOST, PORT, DB
def identity_of(cur):
return tool.target_identity(cur, Args())
# ── 合成表结构 + 哨兵数据 ───────────────────────────────────────────
DDL = [
"DROP TABLE IF EXISTS applications",
"DROP TABLE IF EXISTS b01_demo_ledger",
"DROP TABLE IF EXISTS jobs",
"DROP TABLE IF EXISTS companies",
"DROP TABLE IF EXISTS user_roles",
"DROP TABLE IF EXISTS users",
"""CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY, phone VARCHAR(20) NOT NULL UNIQUE,
email VARCHAR(100), nickname VARCHAR(50), avatar_url VARCHAR(500),
password_hash VARCHAR(255), role VARCHAR(30) NOT NULL DEFAULT 'STUDENT',
is_active TINYINT(1) NOT NULL DEFAULT 1, is_verified TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE user_roles (
user_id BIGINT NOT NULL, role VARCHAR(30) NOT NULL, PRIMARY KEY (user_id, role),
CONSTRAINT fk_ur FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE companies (
id BIGINT AUTO_INCREMENT PRIMARY KEY, owner_user_id BIGINT NOT NULL,
name VARCHAR(120) NOT NULL, logo_url VARCHAR(500), description TEXT,
industry VARCHAR(80), website VARCHAR(255), address VARCHAR(255),
company_size VARCHAR(40) NULL, verified TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_owner UNIQUE (owner_user_id),
CONSTRAINT fk_co FOREIGN KEY (owner_user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE jobs (
id BIGINT AUTO_INCREMENT PRIMARY KEY, company_id BIGINT NOT NULL,
title VARCHAR(120) NOT NULL, description TEXT NOT NULL, requirements TEXT,
city VARCHAR(80) NOT NULL, employment_type VARCHAR(30) NOT NULL,
salary_min INT, salary_max INT, status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
published_at DATETIME, experience VARCHAR(40), education VARCHAR(40),
benefits_json TEXT, salary_months INT, work_location VARCHAR(255),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_job_co FOREIGN KEY (company_id) REFERENCES companies(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE applications (
id BIGINT AUTO_INCREMENT PRIMARY KEY, job_id BIGINT NOT NULL,
student_user_id BIGINT NOT NULL,
CONSTRAINT fk_app_job FOREIGN KEY (job_id) REFERENCES jobs(id),
CONSTRAINT fk_app_stu FOREIGN KEY (student_user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
]
def reset_schema(cur):
for stmt in DDL:
cur.execute(stmt)
def seed_sentinels(cur):
cur.execute("INSERT INTO users(phone, email, nickname, role, is_active, is_verified) "
"VALUES('13900000001','s1@x.com','哨兵甲','STUDENT',1,0)")
s1 = cur.lastrowid
cur.execute("INSERT INTO user_roles(user_id, role) VALUES(%s,'STUDENT')", (s1,))
cur.execute("INSERT INTO users(phone, nickname, role) VALUES('13900000002','哨兵乙','STUDENT')")
s2 = cur.lastrowid
cur.execute("INSERT INTO companies(owner_user_id, name, description, industry, company_size, "
"verified) VALUES(%s,'哨兵企业(非本批)','哨兵简介','其它','10-19人',0)", (s2,))
sc = cur.lastrowid
cur.execute("INSERT INTO jobs(company_id,title,description,city,employment_type,status,"
"experience,education,benefits_json,salary_months,work_location) "
"VALUES(%s,'哨兵岗位','哨兵描述','北京','FULL_TIME','PUBLISHED','1-3年','本科',"
"'[\"哨兵福利\"]',12,'北京市海淀区')", (sc,))
sj = cur.lastrowid
return dict(user1=s1, user2=s2, company=sc, job=sj)
# ── 快照工具 ────────────────────────────────────────────────────────
def table_rows(cur, table, order="1"):
cur.execute("SELECT * FROM `%s` ORDER BY %s" % (table, order))
cols = [d[0] for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
def norm(rows):
return json.dumps([{k: (None if v is None else str(v)) for k, v in r.items()} for r in rows],
ensure_ascii=False, sort_keys=True)
def snapshot(cur, tables=("users", "user_roles", "companies", "jobs")):
return {t: norm(table_rows(cur, t)) for t in tables}
def count(cur, sql, params=None):
cur.execute(sql, params if params else None)
return int(cur.fetchone()[0])
def demo_counts(cur):
fmt_p = ",".join(["%s"] * len(DEMO_PHONES))
fmt_n = ",".join(["%s"] * len(DEMO_NAMES))
users = count(cur, "SELECT COUNT(*) FROM users WHERE phone IN (%s)" % fmt_p, tuple(DEMO_PHONES))
comps = count(cur, "SELECT COUNT(*) FROM companies WHERE name IN (%s)" % fmt_n, tuple(DEMO_NAMES))
jobs = 0
for phone, title in DEMO_TITLES:
jobs += count(cur, "SELECT COUNT(*) FROM jobs j JOIN companies c ON c.id=j.company_id "
"JOIN users u ON u.id=c.owner_user_id WHERE u.phone=%s AND j.title=%s",
(phone, title))
return dict(users=users, companies=comps, jobs=jobs)
def ledger_state(cur, identity):
if not tool.table_exists(cur, tool.LEDGER_TABLE):
return None
rows = tool.read_ledger(cur, identity)
types = {}
for r in rows:
types[r["resource_type"]] = types.get(r["resource_type"], 0) + 1
return dict(total=len(rows), types=types)
# ══ 测试用例 ════════════════════════════════════════════════════════
def t01_first_import(conn, cur):
reset_schema(cur); seed_sentinels(cur)
ident = identity_of(cur)
created, conflicts = tool.apply_import(conn, cur, ident)
check("T1 空库首次导入无冲突", conflicts == [], conflicts)
check("T1 新建 3 企业/12 岗位(含 3 用户)", created == dict(users=3, companies=3, jobs=12), created)
dc = demo_counts(cur)
check("T1 库内本批计数 = 3/3/12", dc == dict(users=3, companies=3, jobs=12), dc)
ls = ledger_state(cur, ident)
check("T1 账本登记 18 行(3/3/12)", ls and ls["total"] == 18
and ls["types"] == dict(USER=3, COMPANY=3, JOB=12), ls)
def t02_repeat_import_idempotent(conn, cur):
ident = identity_of(cur)
before = snapshot(cur)
before_ledger = ledger_state(cur, ident)
created, conflicts = tool.apply_import(conn, cur, ident)
check("T2 重复导入无冲突且不重复创建",
conflicts == [] and created == dict(users=0, companies=0, jobs=0), (created, conflicts))
check("T2 既有业务资料逐字段不变", snapshot(cur) == before)
check("T2 账本不变", ledger_state(cur, ident) == before_ledger, ledger_state(cur, ident))
def t03_occupied_phone_refused(conn, cur):
reset_schema(cur)
cur.execute("INSERT INTO users(phone,nickname,role,is_active,is_verified) "
"VALUES(%s,'他人账号','STUDENT',1,0)", (DEMO_PHONES[0],))
occ = cur.lastrowid
cur.execute("INSERT INTO user_roles(user_id,role) VALUES(%s,'STUDENT')", (occ,))
conn.commit()
before_u = norm(table_rows(cur, "users")); before_r = norm(table_rows(cur, "user_roles"))
ident = identity_of(cur)
created, conflicts = tool.apply_import(conn, cur, ident)
check("T3 固定手机号被非本批占用 → 拒绝", conflicts and created is None, conflicts[:1])
check("T3 占用账号完全不变(users)", norm(table_rows(cur, "users")) == before_u)
check("T3 占用账号完全不变(user_roles)", norm(table_rows(cur, "user_roles")) == before_r)
check("T3 未创建任何本批业务数据", count(cur, "SELECT COUNT(*) FROM companies") == 0
and count(cur, "SELECT COUNT(*) FROM jobs") == 0)
def t04_nonbatch_company_and_title_refused(conn, cur):
# 4a 同名企业被非本批占用
reset_schema(cur)
cur.execute("INSERT INTO users(phone,nickname,role) VALUES('13911112222','他人','COMPANY')")
other = cur.lastrowid
cur.execute("INSERT INTO companies(owner_user_id,name,description,verified) "
"VALUES(%s,%s,'他人企业',0)", (other, DEMO_NAMES[0]))
oc = cur.lastrowid
conn.commit()
b_co = norm(table_rows(cur, "companies"))
ident = identity_of(cur)
created, conflicts = tool.apply_import(conn, cur, ident)
check("T4a 同名企业被非本批占用 → 拒绝", conflicts and created is None, conflicts[:1])
check("T4a 他方企业不被覆盖/认领", norm(table_rows(cur, "companies")) == b_co)
check("T4a 未新增本批企业/岗位", count(cur, "SELECT COUNT(*) FROM companies") == 1
and count(cur, "SELECT COUNT(*) FROM jobs") == 0)
# 4b 同名岗位:本批企业下已存在同名岗位但账本缺失 → 拒绝,不认领
reset_schema(cur)
ident = identity_of(cur)
tool.apply_import(conn, cur, ident)
cur.execute("DELETE FROM b01_demo_ledger WHERE resource_type='JOB' ORDER BY id LIMIT 1")
conn.commit()
b_j = norm(table_rows(cur, "jobs")); b_led = norm(table_rows(cur, "b01_demo_ledger"))
created, conflicts = tool.apply_import(conn, cur, ident)
check("T4b 同名岗位无本批账本归属 → 拒绝", conflicts and created is None, conflicts[:1])
check("T4b 岗位与账本均不被改写",
norm(table_rows(cur, "jobs")) == b_j and norm(table_rows(cur, "b01_demo_ledger")) == b_led)
def t05_demo_text_without_ledger_not_deleted(conn, cur):
reset_schema(cur)
# 只有演示文字标记、没有账本
cur.execute("INSERT INTO users(phone,nickname,role) VALUES(%s,%s,'COMPANY')",
(DEMO_PHONES[0], tool.DEMO_MARK))
u = cur.lastrowid
cur.execute("INSERT INTO companies(owner_user_id,name,description,verified) "
"VALUES(%s,%s,%s,1)", (u, DEMO_NAMES[0], tool.DEMO_MARK))
c = cur.lastrowid
cur.execute("INSERT INTO jobs(company_id,title,description,city,employment_type,status) "
"VALUES(%s,%s,%s,'上海','FULL_TIME','PUBLISHED')", (c, DEMO_TITLES[0][1], tool.DEMO_MARK))
conn.commit()
before = snapshot(cur)
ident = identity_of(cur)
removed, conflicts = tool.apply_cleanup(conn, cur, ident)
check("T5 无账本 → 拒绝清理", removed is None and conflicts, conflicts[:1])
check("T5 演示文字记录逐字段不变", snapshot(cur) == before)
check("T5 未创建账本表", not tool.table_exists(cur, tool.LEDGER_TABLE))
def t06_ledger_lost_or_identity_changed(conn, cur):
ident = identity_of(cur)
# 6a 账本整表丢失后清理 → 拒绝
reset_schema(cur)
tool.apply_import(conn, cur, ident)
cur.execute("DROP TABLE b01_demo_ledger"); conn.commit()
before = snapshot(cur)
removed, conflicts = tool.apply_cleanup(conn, cur, ident)
check("T6a 账本表丢失 → 拒绝清理", removed is None and conflicts, conflicts[:1])
check("T6a 本批数据未被删除", snapshot(cur) == before)
# 6b 资源身份变化(企业改名)→ 拒绝清理
reset_schema(cur)
tool.apply_import(conn, cur, ident)
cur.execute("UPDATE companies SET name=%s WHERE name=%s", ("被改名企业", DEMO_NAMES[0]))
conn.commit()
b1 = snapshot(cur)
removed, conflicts = tool.apply_cleanup(conn, cur, ident)
check("T6b 企业身份变化 → 拒绝清理", removed is None and conflicts, conflicts[:1])
check("T6b 改名后数据未被删除", snapshot(cur) == b1)
# 6c 账本 ID 指向异常(把某条 JOB 账本指向哨兵岗位)→ 拒绝
reset_schema(cur); seed_sentinels(cur)
tool.apply_import(conn, cur, ident)
sentinel_job = count(cur, "SELECT id FROM jobs WHERE title='哨兵岗位'")
cur.execute("UPDATE b01_demo_ledger SET resource_id=%s WHERE resource_type='JOB' LIMIT 1",
(sentinel_job,)); conn.commit()
b2 = snapshot(cur)
removed, conflicts = tool.apply_cleanup(conn, cur, ident)
check("T6c 账本 ID 指向异常 → 拒绝清理", removed is None and conflicts, conflicts[:1])
check("T6c 哨兵与业务数据均未被删除", snapshot(cur) == b2)
def t07_external_dependency_refused(conn, cur):
reset_schema(cur); seed_sentinels(cur)
ident = identity_of(cur)
tool.apply_import(conn, cur, ident)
# 新增非本批依赖:学生投递到本批岗位
cur.execute("INSERT INTO users(phone,nickname,role) VALUES('13922223333','外部学生','STUDENT')")
stu = cur.lastrowid
job = count(cur, "SELECT j.id FROM jobs j JOIN companies c ON c.id=j.company_id "
"JOIN users u ON u.id=c.owner_user_id WHERE u.phone=%s AND j.title=%s",
(DEMO_TITLES[0][0], DEMO_TITLES[0][1]))
cur.execute("INSERT INTO applications(job_id, student_user_id) VALUES(%s,%s)", (job, stu)); conn.commit()
before = snapshot(cur)
removed, conflicts = tool.apply_cleanup(conn, cur, ident)
check("T7 存在本批之外依赖 → 拒绝清理", removed is None and conflicts, conflicts[:1])
check("T7 本批数据与外部依赖全部保留", snapshot(cur) == before)
def t08_midway_error_rolls_back(conn, cur):
reset_schema(cur)
ident = identity_of(cur)
calls = {"n": 0}
real = tool.insert_ledger
def boom(*a, **k):
calls["n"] += 1
if calls["n"] == 3: # 已在事务中间
raise RuntimeError("injected failure")
return real(*a, **k)
tool.insert_ledger = boom
try:
raised = False
try:
tool.apply_import(conn, cur, ident)
except Exception:
raised = True
check("T8 中途异常被抛出", raised and calls["n"] >= 3, calls)
finally:
tool.insert_ledger = real
check("T8 本批业务数据整体回滚(users)", count(cur, "SELECT COUNT(*) FROM users") == 0)
check("T8 本批业务数据整体回滚(companies)", count(cur, "SELECT COUNT(*) FROM companies") == 0)
check("T8 本批业务数据整体回滚(jobs)", count(cur, "SELECT COUNT(*) FROM jobs") == 0)
ls = ledger_state(cur, ident)
check("T8 归属记账一并回滚(账本 0 行)", ls is not None and ls["total"] == 0, ls)
def t09_dry_run_and_preview_zero_write(conn, cur):
reset_schema(cur); seed_sentinels(cur); conn.commit()
before = snapshot(cur)
cmd = [sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)),
"b01_demo_display_data.py"),
"--host", HOST, "--port", str(PORT), "--database", DB,
"--user", APP_USER, "--password", APP_PASS]
r = _run(cmd)
cur2 = conn.cursor()
check("T9 导入 dry-run 退出码 0", r.returncode == 0, r.stdout[-200:])
check("T9 导入 dry-run 零写入且不建账本表",
snapshot(cur2) == before and not tool.table_exists(cur2, tool.LEDGER_TABLE))
# 导入后清理预览
ident = identity_of(cur2)
tool.apply_import(conn, cur2, ident)
b2 = snapshot(cur2)
ls_before = ledger_state(cur2, ident)
r2 = _run(cmd + ["--cleanup"])
check("T9 清理预览退出码 0", r2.returncode == 0, r2.stdout[-200:])
check("T9 清理预览零写入", snapshot(cur2) == b2 and ledger_state(cur2, ident) == ls_before)
def t10_cleanup_only_batch_and_sentinels_intact(conn, cur):
reset_schema(cur)
sent = seed_sentinels(cur)
ident = identity_of(cur)
tool.apply_import(conn, cur, ident)
sent_ids = {"users": [sent["user1"], sent["user2"]], "companies": [sent["company"]],
"jobs": [sent["job"]]}
sent_before = {
"users": norm([r for r in table_rows(cur, "users") if r["id"] in sent_ids["users"]]),
"companies": norm([r for r in table_rows(cur, "companies") if r["id"] in sent_ids["companies"]]),
"jobs": norm([r for r in table_rows(cur, "jobs") if r["id"] in sent_ids["jobs"]]),
"user_roles": norm([r for r in table_rows(cur, "user_roles")
if r["user_id"] in sent_ids["users"]]),
}
removed, conflicts = tool.apply_cleanup(conn, cur, ident)
check("T10 正常清理无冲突", conflicts == [], conflicts)
check("T10 仅删除本批 12 岗位/3 企业/3 用户/18 账本",
removed == dict(jobs=12, companies=3, users=3, ledger=18), removed)
dc = demo_counts(cur)
check("T10 本批业务数据已清空", dc == dict(users=0, companies=0, jobs=0), dc)
check("T10 账本已清空", ledger_state(cur, ident)["total"] == 0)
sent_after = {
"users": norm([r for r in table_rows(cur, "users") if r["id"] in sent_ids["users"]]),
"companies": norm([r for r in table_rows(cur, "companies") if r["id"] in sent_ids["companies"]]),
"jobs": norm([r for r in table_rows(cur, "jobs") if r["id"] in sent_ids["jobs"]]),
"user_roles": norm([r for r in table_rows(cur, "user_roles")
if r["user_id"] in sent_ids["users"]]),
}
check("T10 哨兵记录逐字段不变(users/companies/jobs/user_roles)", sent_after == sent_before)
def t11_second_cleanup_noop(conn, cur):
ident = identity_of(cur)
before = snapshot(cur)
removed, conflicts = tool.apply_cleanup(conn, cur, ident)
check("T11 再次清理安全无操作", conflicts == [] and removed == dict(jobs=0, companies=0,
users=0, ledger=0), (removed, conflicts))
check("T11 库内无变化", snapshot(cur) == before)
def t12_non_whitelist_target_refused_before_connect(conn, cur):
ok = True
for host, port, db in [("127.0.0.1", 3308, "mydreamjob_b01_demo"),
("127.0.0.1", 3308, "mydreamjob_b01_demo_test")]:
try:
tool.assert_safe_target(host, port, db)
except SystemExit:
ok = False
check("T12 白名单目标通过", ok)
for host, port, db, why in [
("127.0.0.1", 3307, "mydreamjob_b01_demo", "非白名单端口"),
("10.0.0.9", 3308, "mydreamjob_b01_demo", "非回环 host"),
("127.0.0.1", 3308, "some_other_db", "非白名单库"),
("127.0.0.1", 3308, "mydreamjob_prod", "疑似线上库")]:
try:
tool.assert_safe_target(host, port, db)
check("T12 拒绝 %s" % why, False, "未拒绝")
except SystemExit as e:
check("T12 拒绝 %s" % why, "REFUSED" in str(e), str(e)[:60])
# 进程级:错误目标必须在**建立连接前**立即拒绝(不等待连接超时)
cmd = [sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)),
"b01_demo_display_data.py"),
"--host", "127.0.0.1", "--port", "3307", "--database", "mydreamjob_b01_demo",
"--user", "nobody", "--password", "nope"]
t0 = time.time()
r = _run(cmd)
elapsed = time.time() - t0
check("T12 非白名单目标非零退出且快速拒绝",
r.returncode != 0 and "REFUSED" in (r.stderr + r.stdout) and elapsed < 10,
"rc=%s elapsed=%.1fs" % (r.returncode, elapsed))
def assert_refusal_preserves_rows(conn, cur, ident, label):
before = snapshot(cur, ("users", "user_roles", "companies", "jobs", tool.LEDGER_TABLE))
_, conflicts = tool.apply_cleanup(conn, cur, ident)
check(label + " 清理拒绝", bool(conflicts), conflicts)
check(label + " 清理未改业务或账本", snapshot(cur, tuple(before)) == before)
_, conflicts = tool.apply_import(conn, cur, ident)
check(label + " 导入拒绝", bool(conflicts), conflicts)
check(label + " 导入未改业务或账本", snapshot(cur, tuple(before)) == before)
def t13_real_user_identity_changed(conn, cur):
reset_schema(cur); seed_sentinels(cur)
ident = identity_of(cur)
tool.apply_import(conn, cur, ident)
cur.execute("UPDATE users SET phone='18800009991' WHERE phone=%s", (DEMO_PHONES[0],))
conn.commit()
assert_refusal_preserves_rows(conn, cur, ident, "T13 真实用户身份改变")
def t14_job_parent_changed(conn, cur):
reset_schema(cur); seed_sentinels(cur)
ident = identity_of(cur)
tool.apply_import(conn, cur, ident)
ledger = tool.read_ledger(cur, ident)
job = next(r for r in ledger if r['resource_type'] == 'JOB')
cur.execute("SELECT company_id FROM jobs WHERE id=%s", (job['resource_id'],))
old = cur.fetchone()[0]
other = next(r['resource_id'] for r in ledger if r['resource_type'] == 'COMPANY' and r['resource_id'] != old)
cur.execute("UPDATE jobs SET company_id=%s WHERE id=%s", (other, job['resource_id']))
conn.commit()
assert_refusal_preserves_rows(conn, cur, ident, "T14 岗位父企业改变")
def t15_company_name_changed(conn, cur):
reset_schema(cur); seed_sentinels(cur)
ident = identity_of(cur)
tool.apply_import(conn, cur, ident)
cur.execute("UPDATE companies SET name='changed-identity' WHERE name=%s", (DEMO_NAMES[0],))
conn.commit()
assert_refusal_preserves_rows(conn, cur, ident, "T15 企业真实名字改变")
TESTS = [
("T1", t01_first_import),
("T2", t02_repeat_import_idempotent),
("T3", t03_occupied_phone_refused),
("T4", t04_nonbatch_company_and_title_refused),
("T5", t05_demo_text_without_ledger_not_deleted),
("T6", t06_ledger_lost_or_identity_changed),
("T7", t07_external_dependency_refused),
("T8", t08_midway_error_rolls_back),
("T9", t09_dry_run_and_preview_zero_write),
("T10", t10_cleanup_only_batch_and_sentinels_intact),
("T11", t11_second_cleanup_noop),
("T12", t12_non_whitelist_target_refused_before_connect),
("T13", t13_real_user_identity_changed),
("T14", t14_job_parent_changed),
("T15", t15_company_name_changed),
]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--keep-container", action="store_true")
parser.add_argument("--out")
args = parser.parse_args()
try:
start_container()
except Exception as exc:
print("[ENV] 无法启动隔离 MySQL 容器:%s" % exc)
return 2
try:
admin = root_conn()
with admin.cursor() as ac:
ac.execute("CREATE USER IF NOT EXISTS '%s'@'%%' IDENTIFIED BY '%s'" % (APP_USER, APP_PASS))
ac.execute("GRANT ALL PRIVILEGES ON `%s`.* TO '%s'@'%%'" % (DB, APP_USER))
ac.execute("FLUSH PRIVILEGES")
admin.close()
for name, fn in TESTS:
conn = root_conn(autocommit=False)
try:
with conn.cursor() as cur:
fn(conn, cur)
except Exception as exc:
import traceback
check("%s 执行异常" % name, False, traceback.format_exc()[-400:])
finally:
try:
conn.rollback()
except Exception:
pass
conn.close()
finally:
if not args.keep_container:
stop_container()
bad = [r for r in RESULTS if not r["ok"]]
print("\n== DEMO-TOOL SAFETY SUMMARY: total=%d fail=%d ==" % (len(RESULTS), len(bad)))
if args.out:
with open(args.out, "w", encoding="utf-8") as fh:
json.dump(RESULTS, fh, ensure_ascii=False, indent=1)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())

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;本轮不暂存、不提交、不推送、不部署。上线开关和已有生产账号由后续独立验收与发布检查点另行处理。

214
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) print('paths:', len(d['paths']))
assert len(d['paths']) == 182, f"Expected 182 paths, got {len(d['paths'])}" assert len(d['paths']) == 205, f"Expected 205 paths, got {len(d['paths'])}"
paths = d['paths'] paths = d['paths']
@ -62,7 +62,9 @@ assert used_tags <= declared_tags, f'Undeclared operation tags: {sorted(used_tag
required = [ required = [
# Phase A 企业注册与入驻聚合(2 个 path / 2 个 operation) # Phase A 企业注册与入驻聚合(2 个 path / 2 个 operation)
'/auth/company/register', '/auth/company/register',
'/auth/identity/select',
'/companies/me/onboarding', '/companies/me/onboarding',
'/companies/me/jobs',
'/review-weight-templates', '/review-weight-templates',
'/profile/me/review-weights', '/profile/me/review-weights',
'/profile/me/privacy', '/profile/me/privacy',
@ -647,7 +649,7 @@ assert _auth_paths == {
'/auth/sms/send', '/auth/login', '/auth/me', '/auth/sms/send', '/auth/login', '/auth/me',
'/auth/refresh', '/auth/logout', '/auth/refresh', '/auth/logout',
'/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset', '/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)}' }, f'Unexpected /auth paths: {sorted(_auth_paths)}'
print(' S02 refresh/logout and password authentication operations validated') 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 assert _forbidden not in _onboarding_text
print(' Phase A company registration/onboarding paths and redacted schemas validated') 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:唯一新增的企业简历下载接口 ──────────────────────────────── # ── S03:唯一新增的企业简历下载接口 ────────────────────────────────
_download_path = '/company/candidates/{applicationId}/resume' _download_path = '/company/candidates/{applicationId}/resume'
assert _download_path in paths, 'Missing company resume download endpoint' assert _download_path in paths, 'Missing company resume download endpoint'
@ -702,17 +716,209 @@ for _p in paths:
assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}' 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') print(' S03 company resume download contract validated; candidate DTOs remain redacted')
# 统计 operation 总数并校验 V43 + N07 + S02 + S03 + Phase A 计数 220 # 统计 operation 总数并校验当前接口契约基线。
operations = sum(len(methods) for methods in d['paths'].values()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) print('operations:', operations)
assert operations == 220, f"Expected 220 operations, got {operations}" assert operations == 253, f"Expected 253 operations, got {operations}"
assert 'post' in paths['/conversations/{id}/resume-messages']
assert {'get', 'delete'} <= paths['/conversations/{conversationId}/messages/{messageId}/resume'].keys()
assert {'messageType', 'resume'} <= d['components']['schemas']['MessageResponse']['properties'].keys()
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(" Offer advance endpoints all present")
print(" 26 operations all present") print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)") print(" Account deletion endpoints present (3 operations)")
schemas = {name.removeprefix('Lxq'): schema for name, schema in
d['components']['schemas'].items() if name.startswith('Lxq')}
onboarding_success_refs = {
('/onboarding/companies/{companyId}/checklist', 'get'): 'ApiResponseEffectiveChecklistResponse',
('/onboarding/companies/{companyId}/checklist/items/{itemId}/completion', 'put'): 'ApiResponseProgressUpdateResponse',
('/onboarding/companies/{companyId}/knowledge', 'get'): 'ApiResponseCompanyKnowledgeOverviewResponse',
('/onboarding/company/checklist-items', 'get'): 'ApiResponseListCompanyChecklistItemView',
('/onboarding/company/checklist-items', 'post'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/checklist-items/{itemId}', 'put'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/checklist-items/{itemId}', 'delete'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/knowledge', 'get'): 'ApiResponseCompanyKnowledgeView',
('/onboarding/company/knowledge', 'put'): 'ApiResponseCompanyKnowledgeView',
}
for (path, method), schema_name in onboarding_success_refs.items():
response = paths[path][method]['responses']['200']
ref = response['content']['*/*']['schema']['$ref']
assert ref == f'#/components/schemas/Lxq{schema_name}', f'Wrong success schema for {method.upper()} {path}: {ref}'
for status_code in ('401', '403'):
assert status_code in paths[path][method]['responses'], f'Missing {status_code} for {method.upper()} {path}'
unauthorized = paths[path][method]['responses']['401']
assert unauthorized['content']['application/json']['schema']['$ref'] == '#/components/schemas/LxqUnauthorizedResponse'
forbidden = paths[path][method]['responses']['403']
assert forbidden['content']['application/json']['schema']['$ref'] == '#/components/schemas/LxqApiResponse'
assert forbidden['content']['application/json']['example']['code'] == 2002
onboarding_schema_fields = {
'CompanyChecklistItemCreateRequest': {'title', 'description', 'category', 'sortOrder', 'required', 'idempotencyKey'},
'CompanyChecklistItemUpdateRequest': {'title', 'description', 'category', 'sortOrder', 'required', 'active', 'version'},
'CompanyChecklistItemView': {'itemId', 'source', 'itemKey', 'title', 'description', 'category', 'required', 'sortOrder', 'active', 'version'},
'EffectiveChecklistResponse': {'companyId', 'companyName', 'customized', 'totalItems', 'completedItems', 'completionRate', 'items'},
'ChecklistItemView': {'itemId', 'source', 'itemKey', 'title', 'description', 'category', 'required', 'sortOrder', 'active', 'completed', 'completedAt', 'progressVersion'},
'ProgressUpdateRequest': {'completed', 'version'},
'ProgressUpdateResponse': {'itemId', 'completed', 'completedAt', 'progressVersion', 'totalItems', 'completedItems', 'completionRate'},
'CompanyKnowledgeUpdateRequest': {'culture', 'workPolicies', 'onboardingGuide', 'version'},
'CompanyKnowledgeView': {'configured', 'culture', 'workPolicies', 'onboardingGuide', 'updatedAt', 'version'},
'CompanyKnowledgeOverviewResponse': {'company', 'knowledgeState', 'knowledge', 'reviewSummary'},
'CompanyView': {'id', 'name', 'logoUrl', 'description', 'industry', 'website', 'address', 'verified'},
'KnowledgeView': {'culture', 'workPolicies', 'onboardingGuide', 'updatedAt', 'version'},
'ReviewSummaryView': {'state', 'totalReviews', 'averageRating', 'ratingDistribution', 'topTags', 'dimensionSummary'},
'DimensionSummaryView': {'averageScore', 'sampleCount', 'dataState'},
}
for schema_name, expected_fields in onboarding_schema_fields.items():
schema = schemas.get(schema_name)
assert schema is not None, f'Missing onboarding schema: {schema_name}'
assert expected_fields <= set(schema.get('properties', {})), (
f'Onboarding schema fields missing: {schema_name}'
)
create_request = schemas['CompanyChecklistItemCreateRequest']['properties']
assert create_request['sortOrder'].get('default') == 1000, (
'CompanyChecklistItemCreateRequest.sortOrder default must be 1000'
)
assert create_request['required'].get('default') is False, (
'CompanyChecklistItemCreateRequest.required default must be false'
)
knowledge_success_example = paths['/onboarding/company/knowledge']['put']['responses']['200']['content']['*/*']['example']
assert knowledge_success_example['data'].get('version') == 2, (
'PUT /onboarding/company/knowledge 200 example data.version must be 2'
)
onboarding_error_paths = {
10501: ('/onboarding/company/checklist-items/{itemId}', 'put', '404'),
10502: ('/onboarding/company/checklist-items/{itemId}', 'put', '409'),
10503: ('/onboarding/company/checklist-items', 'post', '409'),
10504: ('/onboarding/company/checklist-items', 'post', '409'),
10505: ('/onboarding/companies/{companyId}/checklist/items/{itemId}/completion', 'put', '409'),
10506: ('/onboarding/company/knowledge', 'put', '409'),
}
for error_code, (path, method, status_code) in onboarding_error_paths.items():
response_text = json.dumps(paths[path][method]['responses'][status_code], ensure_ascii=False)
assert str(error_code) in response_text, f'Missing onboarding error code {error_code} in {method.upper()} {path}'
print(' N04 onboarding endpoints and schemas present (9 operations / 6 paths)')
# ── 岗位与企业展示字段批次:新增字段、约束与公开摘要白名单 ─────────────
# 本批未新增端点(paths=201 / operations=245 基线不变),只扩展既有 schema 与响应说明。
# 运行时为 OpenAPI 3.1,可空性以 `type: [<base>, "null"]` 表达(不再使用 3.0 的 nullable)。
def _type_set(node):
value = node.get('type')
return set(value) if isinstance(value, list) else {value}
_display_text_fields = {'experience': 40, 'education': 40, 'workLocation': 255}
for _schema_name in ('JobCreateRequest', 'JobUpdateRequest'):
_props = d['components']['schemas'][_schema_name]['properties']
for _field, _max in _display_text_fields.items():
assert _field in _props, f'{_schema_name} missing display field {_field}'
assert _type_set(_props[_field]) == {'string', 'null'}, \
f'{_schema_name}.{_field} 必须为可空 string'
assert _props[_field]['maxLength'] == _max, f'{_schema_name}.{_field} maxLength must be {_max}'
_benefits = _props['benefits']
assert _type_set(_benefits) == {'array', 'null'}, f'{_schema_name}.benefits must be nullable array'
assert _benefits['items']['type'] == 'string', f'{_schema_name}.benefits items must be string'
assert 'maxItems' not in _benefits and 'maxLength' not in _benefits['items'], '请求原始福利不得施加归一化后的限制'
assert _benefits.get('x-normalization') == dict(trimItems=True, deduplicate=True, rejectBlankItems=True,
maxItems=20, itemMaxLength=40), '福利归一化契约缺失或错误'
_months = _props['salaryMonths']
assert _type_set(_months) == {'integer', 'null'}, \
f'{_schema_name}.salaryMonths must be nullable integer (非整数不允许)'
assert _months['minimum'] == 1 and _months['maximum'] == 24, \
f'{_schema_name}.salaryMonths must be 1..24'
for _salary in ('salaryMin', 'salaryMax'):
_desc = _props[_salary].get('description', '')
assert '元' in _desc, f'{_schema_name}.{_salary} 单位仍应为元/月'
print(' 岗位展示字段 experience/education/benefits/salaryMonths/workLocation 约束已验证')
for _schema_name in ('CompanyCreateRequest', 'CompanyUpdateRequest'):
_cs = d['components']['schemas'][_schema_name]['properties']['companySize']
assert _type_set(_cs) == {'string', 'null'}, f'{_schema_name}.companySize must be nullable string'
assert _cs['maxLength'] == 40, f'{_schema_name}.companySize maxLength must be 40'
assert '空白' in _cs.get('description', ''), f'{_schema_name}.companySize 写入语义(空白清空)须写明'
print(' companySize 请求字段约束与写入语义已验证')
_managed_props = d['components']['schemas']['CompanyManagedJobItem']['properties']
for _field in ('experience', 'education', 'benefits', 'salaryMonths', 'workLocation'):
assert _field in _managed_props, f'CompanyManagedJobItem missing display field {_field}'
assert '元' in _managed_props['salaryMin'].get('description', '')
assert '元' in _managed_props['salaryMax'].get('description', '')
print(' CompanyManagedJobItem 展示字段已验证')
_public_company_props = d['components']['schemas']['CompanyPublicView']['properties']
assert set(_public_company_props) == {
'id', 'name', 'logoUrl', 'description', 'industry', 'verified', 'companySize'}, \
f'CompanyPublicView 必须严格为 7 字段白名单,实际={sorted(_public_company_props)}'
for _forbidden in ('ownerUserId', 'address', 'website', 'phone', 'email', 'createdAt',
'materialsJson', 'decisionReason', 'reviewerId'):
assert _forbidden not in _public_company_props, f'CompanyPublicView 不得包含 {_forbidden}'
print(' 公开企业摘要严格 7 字段白名单已验证(不含 ownerUserId/联系方式/地址/认证材料/审核信息)')
for _pt, _method in [('/jobs', 'get'), ('/jobs', 'post'), ('/jobs/search', 'get'),
('/jobs/public/{jobId}', 'get'), ('/jobs/{jobId}', 'get'),
('/jobs/{jobId}', 'put'), ('/companies/{companyId}/jobs', 'get')]:
_desc = paths[_pt][_method]['responses']['200'].get('description', '')
assert all(_f in _desc for _f in ('experience', 'education', 'benefits', 'salaryMonths', 'workLocation')), \
f'{_method.upper()} {_pt} 200 说明须列出岗位展示字段'
assert '元/月' in _desc, f'{_method.upper()} {_pt} 200 说明须写明薪单位仍为元/月'
_company_summary_desc = paths['/companies/{companyId}']['get']['responses']['200'].get('description', '')
assert 'companySize' in _company_summary_desc and 'ownerUserId' in _company_summary_desc
print(' Map 型岗位接口与公开企业摘要的展示字段说明已验证')
data = json.dumps(d) data = json.dumps(d)
assert '"" : "#/components/schemas/' not in data, "Found empty ref key" assert '"" : "#/components/schemas/' not in data, "Found empty ref key"
print("No empty ref keys found") print("No empty ref keys found")
_search_parameters = {p['name']: p for p in paths['/jobs/search']['get']['parameters']}
for _name in ('industry', 'experience', 'education'):
assert _search_parameters[_name]['in'] == 'query'
assert _search_parameters[_name]['required'] is False
assert _search_parameters[_name]['schema']['type'] == 'string'
for _option in ('industries', 'experiences', 'educations'):
assert _option in paths['/jobs/filters']['get']['description']
print(' 岗位行业/经验/学历多选参数和动态选项说明已验证')
_online = '/profile/me/online-resume'
assert set(paths[_online]) & HTTP_METHODS == {'get', 'put'}
assert set(paths[_online + '/{section}']) & HTTP_METHODS == {'get', 'post'}
assert set(paths[_online + '/{section}/{id}']) & HTTP_METHODS == {'get', 'put', 'delete'}
assert 'get' in paths[_online + '/download']
_online_schemas = d['components']['schemas']
assert {'phone', 'email'} <= set(_online_schemas['OnlineResumeBasicInfo']['properties'])
assert {'educationExperiences', 'workExperiences', 'projectExperiences', 'skills', 'selfEvaluation'} <= set(_online_schemas['OnlineResumeContent']['properties'])
assert {'jobSeekingStatus', 'employmentStatus', 'availableFrom'} <= set(_online_schemas['OnlineResumeJobIntention']['properties'])
assert {'fileName', 'contentType', 'size', 'downloadUrl'} <= set(_online_schemas['OnlineResumeAttachment']['properties'])
assert '6002' in paths[_online]['put']['description']
_entry = _online_schemas['OnlineResumeEntryRequest']['properties']['entry']
assert '$ref' not in _entry
assert len(_entry['oneOf']) == 4
assert len({item['$ref'] for item in _entry['oneOf']}) == 4
for _candidate in ('CandidateItemView', 'CandidateDetailView'):
assert not ({'phone', 'email'} & set(_online_schemas[_candidate]['properties']))
print(' Online resume: 4 paths / 8 operations, contacts and experience schemas verified')
_work_type = _online_schemas['OnlineResumeWork']['properties']['experienceType']
assert set(_work_type['type']) == {'string', 'null'}
assert _work_type['pattern'] == '^(FULL_TIME|INTERNSHIP)$'
assert 'experienceType' not in _online_schemas['OnlineResumeWork'].get('required', [])
assert '未区分' in _work_type['description']
for _legacy_schema in ('StudentProfileUpdateRequest', 'StudentPreferenceRequest'):
_schema = _online_schemas[_legacy_schema]
_revision = _schema['properties']['revision']
assert _revision['type'] == 'integer' and _revision['format'] == 'int64'
assert _revision['minimum'] == 0
assert '409/6002' in _revision['description']
assert 'revision' not in _schema.get('required', []), '无在线记录的旧客户端仍可省略版本'
for _legacy_path in ('/profile/me', '/profile/me/preferences'):
assert '409/6002' in paths[_legacy_path]['put']['description']
assert 'revision' in paths[_legacy_path]['put']['description']
print(' Legacy profile/preference writes share online resume revision; conditional requirement documented')
print("Valid JSON: True") print("Valid JSON: True")
print("All checks passed!") print("All checks passed!")

4
mydreamjob-server/README.md

@ -1,6 +1,6 @@
# 职来 (mydreamjob) 后端服务 # 轻松聘 (mydreamjob) 后端服务
> Spring Boot 3.2.5 + Java 17 | 职来大学生求职服务平台后端基线 > Spring Boot 3.2.5 + Java 17 | 轻松聘大学生求职服务平台后端基线
## 项目结构 ## 项目结构

12
mydreamjob-server/pom.xml

@ -15,7 +15,7 @@
<artifactId>mydreamjob-server</artifactId> <artifactId>mydreamjob-server</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.0.1-SNAPSHOT</version>
<name>mydreamjob-server</name> <name>mydreamjob-server</name>
<description>职来 (mydreamjob) Backend - Week 1 Baseline</description> <description>轻松聘 (mydreamjob) Backend - Week 1 Baseline</description>
<properties> <properties>
<java.version>17</java.version> <java.version>17</java.version>
@ -25,6 +25,16 @@
</properties> </properties>
<dependencies> <dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- 阿里云国内短信官方 SDK:凭据仅从服务器环境注入 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>4.6.0</version>
</dependency>
<!-- Spring Boot Starters --> <!-- Spring Boot Starters -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

18
mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java

@ -20,8 +20,11 @@ import java.time.LocalDateTime;
@AllArgsConstructor @AllArgsConstructor
@Entity @Entity
@Table(name = "applications", @Table(name = "applications",
uniqueConstraints = @UniqueConstraint(name = "uk_applications_job_student", uniqueConstraints = {
columnNames = {"job_id", "student_user_id"})) @UniqueConstraint(name = "uk_applications_job_student_attempt",
columnNames = {"job_id", "student_user_id", "attempt_number"}),
@UniqueConstraint(name = "uk_applications_job_student_active",
columnNames = {"job_id", "student_user_id", "active_application_key"})})
@EntityListeners(AuditingEntityListener.class) @EntityListeners(AuditingEntityListener.class)
public class Application { public class Application {
@ -35,6 +38,17 @@ public class Application {
@Column(name = "student_user_id", nullable = false) @Column(name = "student_user_id", nullable = false)
private Long studentUserId; private Long studentUserId;
@Column(name = "attempt_number", nullable = false)
@Builder.Default
private Integer attemptNumber = 1;
@Column(name = "previous_application_id")
private Long previousApplicationId;
/** 数据库按状态生成;被拒绝、已录用仍占位,不能借重投绕过终态规则。 */
@Column(name = "active_application_key", insertable = false, updatable = false)
private Integer activeApplicationKey;
@Column(name = "cover_letter", columnDefinition = "text") @Column(name = "cover_letter", columnDefinition = "text")
private String coverLetter; private String coverLetter;

10
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java

@ -52,7 +52,7 @@ public class ApplicationController {
// ── 学生投递 ────────────────────────────────────────────── // ── 学生投递 ──────────────────────────────────────────────
@Operation(summary = "投递岗位", description = "只能投递 PUBLISHED 状态的岗位,不可重复投递") @Operation(summary = "投递岗位", description = "只能投递 PUBLISHED 岗位;仅所有历史投递均已撤回时可重投。重投返回新 id、attemptNumber 和 previousApplicationId,不继承旧匹配或授权;进行中、已拒绝、已录用仍返回重复投递错误。")
@PostMapping("/applications") @PostMapping("/applications")
public ResponseEntity<ApiResponse<Map<String, Object>>> apply( public ResponseEntity<ApiResponse<Map<String, Object>>> apply(
@Valid @RequestBody ApplicationCreateRequest req, @Valid @RequestBody ApplicationCreateRequest req,
@ -64,7 +64,7 @@ public class ApplicationController {
// ── 学生撤回投递 ────────────────────────────────────────── // ── 学生撤回投递 ──────────────────────────────────────────
@Operation(summary = "撤回投递", description = "仅 SUBMITTED/VIEWED 状态可撤回,撤回后不可逆") @Operation(summary = "撤回投递", description = "仅 SUBMITTED/VIEWED 可撤回;旧记录不恢复,可通过 POST /applications 创建新一轮投递。")
@PostMapping("/applications/{id}/withdraw") @PostMapping("/applications/{id}/withdraw")
public ResponseEntity<ApiResponse<Map<String, Object>>> withdraw( public ResponseEntity<ApiResponse<Map<String, Object>>> withdraw(
@PathVariable Long id, @PathVariable Long id,
@ -88,7 +88,7 @@ public class ApplicationController {
// ── 学生查看自己的投递 ──────────────────────────────────── // ── 学生查看自己的投递 ────────────────────────────────────
@Operation(summary = "我的投递列表", description = "查看当前学生自己的投递记录,支持 status 筛选和分页") @Operation(summary = "我的投递列表", description = "查看当前学生自己的投递记录,支持 status 筛选和分页;附当前岗位摘要 jobTitle/companyId/companyName/city/salaryMin/salaryMax/jobStatus/employmentType,薪资单位元/月")
@GetMapping("/applications/me") @GetMapping("/applications/me")
public ResponseEntity<ApiResponse<Map<String, Object>>> listMyApplications( public ResponseEntity<ApiResponse<Map<String, Object>>> listMyApplications(
@RequestParam(required = false) String status, @RequestParam(required = false) String status,
@ -97,9 +97,9 @@ public class ApplicationController {
HttpServletRequest request) { HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId(); Long userId = currentUserAccessor.requireUserId();
if (size > 100) size = 100; if (size > 100) size = 100;
Page<Application> result = applicationService.listStudentApplications(userId, status, page, size); Page<Map<String, Object>> result = applicationService.listStudentApplicationViews(userId, status, page, size);
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("items", result.getContent().stream().map(applicationService::toView).collect(Collectors.toList())); data.put("items", result.getContent());
data.put("total", result.getTotalElements()); data.put("total", result.getTotalElements());
data.put("page", result.getNumber()); data.put("page", result.getNumber());
data.put("size", result.getSize()); data.put("size", result.getSize());

12
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java

@ -11,7 +11,17 @@ import java.util.List;
@Repository @Repository
public interface ApplicationRepository extends JpaRepository<Application, Long> { public interface ApplicationRepository extends JpaRepository<Application, Long> {
Optional<Application> findByJobIdAndStudentUserId(Long jobId, Long studentUserId); /** MySQL REPEATABLE READ 下必须当前读,不能在等待岗位锁后继续读取旧快照。 */
@org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
Optional<Application> findFirstByJobIdAndStudentUserIdOrderByAttemptNumberDesc(Long jobId, Long studentUserId);
@org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
Optional<Application> findFirstByJobIdAndStudentUserIdAndStatusNotInOrderByAttemptNumberDesc(
Long jobId, Long studentUserId, java.util.Collection<String> statuses);
@org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
@org.springframework.data.jpa.repository.Query("select a from Application a where a.id = :id")
Optional<Application> findLockedById(@org.springframework.data.repository.query.Param("id") Long id);
List<Application> findByStudentUserIdOrderByCreatedAtDesc(Long studentUserId); List<Application> findByStudentUserIdOrderByCreatedAtDesc(Long studentUserId);

68
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.job.JobService;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; 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.notification.NotificationService;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import com.mydreamjob.profile.StudentProfile; import com.mydreamjob.profile.StudentProfile;
@ -44,6 +47,7 @@ public class ApplicationService {
private final ApplicationRepository applicationRepository; private final ApplicationRepository applicationRepository;
private final JobService jobService; private final JobService jobService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final CompanyRepository companyRepository;
private final AuditService auditService; private final AuditService auditService;
private final ApplicationStatusHistoryRepository historyRepository; private final ApplicationStatusHistoryRepository historyRepository;
private final NotificationService notificationService; private final NotificationService notificationService;
@ -70,12 +74,18 @@ public class ApplicationService {
public Application apply(Long studentUserId, ApplicationCreateRequest req, public Application apply(Long studentUserId, ApplicationCreateRequest req,
com.mydreamjob.audit.AuditContext ctx) { com.mydreamjob.audit.AuditContext ctx) {
assertRole(studentUserId, "STUDENT"); assertRole(studentUserId, "STUDENT");
Job job = jobService.getJob(req.getJobId()); Job job = jobService.getJobForApplication(req.getJobId());
if (!"PUBLISHED".equals(job.getStatus())) throw new BusinessException(ErrorCode.JOB_NOT_FOUND); if (!"PUBLISHED".equals(job.getStatus())) throw new BusinessException(ErrorCode.JOB_NOT_FOUND);
if (applicationRepository.existsByJobIdAndStudentUserId(req.getJobId(), studentUserId)) if (applicationRepository.findFirstByJobIdAndStudentUserIdAndStatusNotInOrderByAttemptNumberDesc(
req.getJobId(), studentUserId, Set.of("WITHDRAWN")).isPresent())
throw new BusinessException(ErrorCode.APPLICATION_DUPLICATE); throw new BusinessException(ErrorCode.APPLICATION_DUPLICATE);
Application previous = applicationRepository
.findFirstByJobIdAndStudentUserIdOrderByAttemptNumberDesc(req.getJobId(), studentUserId)
.orElse(null);
Application app = Application.builder() Application app = Application.builder()
.jobId(req.getJobId()).studentUserId(studentUserId) .jobId(req.getJobId()).studentUserId(studentUserId)
.attemptNumber(previous == null ? 1 : Math.addExact(previous.getAttemptNumber(), 1))
.previousApplicationId(previous == null ? null : previous.getId())
.status(ApplicationStatus.SUBMITTED.name()).build(); .status(ApplicationStatus.SUBMITTED.name()).build();
app = applicationRepository.save(app); app = applicationRepository.save(app);
recordHistory(app.getId(), studentUserId, null, "SUBMITTED", "Student applied"); recordHistory(app.getId(), studentUserId, null, "SUBMITTED", "Student applied");
@ -101,7 +111,7 @@ public class ApplicationService {
public Application withdraw(Long studentUserId, Long applicationId, public Application withdraw(Long studentUserId, Long applicationId,
com.mydreamjob.audit.AuditContext ctx) { com.mydreamjob.audit.AuditContext ctx) {
assertRole(studentUserId, "STUDENT"); assertRole(studentUserId, "STUDENT");
Application app = applicationRepository.findById(applicationId) Application app = applicationRepository.findLockedById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
if (!app.getStudentUserId().equals(studentUserId)) if (!app.getStudentUserId().equals(studentUserId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
@ -132,12 +142,18 @@ public class ApplicationService {
/** 状态时间线 */ /** 状态时间线 */
public List<Map<String, Object>> getTimeline(Long applicationId, Long userId) { 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) Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
// 仅学生本人或岗位所属企业可查看 // 仅学生本人或岗位所属企业可查看
Job job = jobService.getJob(app.getJobId()); Job job = jobService.getJob(app.getJobId());
boolean isStudent = app.getStudentUserId().equals(userId); 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); if (!isStudent && !isCompany) throw new BusinessException(ErrorCode.FORBIDDEN);
return historyRepository.findByApplicationIdOrderByCreatedAtAsc(applicationId) return historyRepository.findByApplicationIdOrderByCreatedAtAsc(applicationId)
@ -161,6 +177,35 @@ public class ApplicationService {
return applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(studentUserId, pr); return applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(studentUserId, pr);
} }
/** 仅补齐本人当前页的岗位摘要;批量投影查询,禁止逐条反查岗位或企业。 */
@Transactional(readOnly = true)
public Page<Map<String, Object>> listStudentApplicationViews(
Long studentUserId, String status, int page, int size) {
Page<Application> applications = listStudentApplications(studentUserId, status, page, size);
Map<Long, Object[]> summaries = new HashMap<>();
if (!applications.isEmpty()) {
List<Long> jobIds = applications.stream().map(Application::getJobId).distinct().toList();
em.createQuery("""
select j.id, j.title, j.companyId, c.name, j.city,
j.salaryMin, j.salaryMax, j.status, j.employmentType
from Job j left join Company c on c.id = j.companyId
where j.id in :jobIds
""", Object[].class)
.setParameter("jobIds", jobIds).getResultList()
.forEach(row -> summaries.put((Long) row[0], row));
}
return applications.map(application -> {
Map<String, Object> view = toView(application);
Object[] row = summaries.get(application.getJobId());
String[] fields = {"jobTitle", "companyId", "companyName", "city",
"salaryMin", "salaryMax", "jobStatus", "employmentType"};
for (int i = 0; i < fields.length; i++) {
view.put(fields[i], row == null ? null : row[i + 1]);
}
return view;
});
}
public Application getApplication(Long applicationId, Long studentUserId) { public Application getApplication(Long applicationId, Long studentUserId) {
Application app = applicationRepository.findById(applicationId) Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
@ -172,6 +217,7 @@ public class ApplicationService {
// ── 企业端 ──────────────────────────────────────────────── // ── 企业端 ────────────────────────────────────────────────
public List<Application> listCompanyApplications(Long companyId, Long jobId) { public List<Application> listCompanyApplications(Long companyId, Long jobId) {
jobService.requireVerifiedCompany(companyId);
Job job = jobService.getJob(jobId); Job job = jobService.getJob(jobId);
if (!job.getCompanyId().equals(companyId)) if (!job.getCompanyId().equals(companyId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
@ -180,6 +226,7 @@ public class ApplicationService {
public Page<Application> listCompanyApplications(Long companyId, Long jobId, String status, int page, int size) { 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); if (page < 0 || size < 1 || size > 100) throw new BusinessException(ErrorCode.BAD_REQUEST);
jobService.requireVerifiedCompany(companyId);
Job job = jobService.getJob(jobId); Job job = jobService.getJob(jobId);
if (!job.getCompanyId().equals(companyId)) if (!job.getCompanyId().equals(companyId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
@ -197,7 +244,8 @@ public class ApplicationService {
@Transactional @Transactional
public Application updateStatus(Long companyId, Long applicationId, ApplicationStatusRequest req, public Application updateStatus(Long companyId, Long applicationId, ApplicationStatusRequest req,
com.mydreamjob.audit.AuditContext ctx) { com.mydreamjob.audit.AuditContext ctx) {
Application app = applicationRepository.findById(applicationId) jobService.requireVerifiedCompany(companyId);
Application app = applicationRepository.findLockedById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId()); Job job = jobService.getJob(app.getJobId());
if (!job.getCompanyId().equals(companyId)) if (!job.getCompanyId().equals(companyId))
@ -233,6 +281,8 @@ public class ApplicationService {
view.put("jobId", app.getJobId()); view.put("jobId", app.getJobId());
view.put("studentUserId", app.getStudentUserId()); view.put("studentUserId", app.getStudentUserId());
view.put("status", app.getStatus()); view.put("status", app.getStatus());
view.put("attemptNumber", app.getAttemptNumber());
view.put("previousApplicationId", app.getPreviousApplicationId());
view.put("createdAt", app.getCreatedAt()); view.put("createdAt", app.getCreatedAt());
view.put("updatedAt", app.getUpdatedAt()); view.put("updatedAt", app.getUpdatedAt());
return view; return view;
@ -265,6 +315,7 @@ public class ApplicationService {
public CandidateListResponse listCompanyCandidates(Long companyId, Long jobId, String status, public CandidateListResponse listCompanyCandidates(Long companyId, Long jobId, String status,
String keyword, String sort, String order, String keyword, String sort, String order,
int page, int size) { int page, int size) {
jobService.requireVerifiedCompany(companyId);
// 参数校验 // 参数校验
if (page < 0 || size < 1 || size > 100) if (page < 0 || size < 1 || size > 100)
throw new BusinessException(ErrorCode.BAD_REQUEST); throw new BusinessException(ErrorCode.BAD_REQUEST);
@ -293,7 +344,7 @@ public class ApplicationService {
String selectClause = "SELECT a.id, a.jobId, j.title, a.status, " + String selectClause = "SELECT a.id, a.jobId, j.title, a.status, " +
"sp.realName, sp.school, sp.education, sp.graduationYear, " + "sp.realName, sp.school, sp.education, sp.graduationYear, " +
"u.avatarUrl, a.createdAt, a.updatedAt"; "u.avatarUrl, a.createdAt, a.updatedAt, a.attemptNumber, a.previousApplicationId";
String fromClause = "FROM Application a " + String fromClause = "FROM Application a " +
"JOIN Job j ON a.jobId = j.id " + "JOIN Job j ON a.jobId = j.id " +
"LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " + "LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " +
@ -333,6 +384,8 @@ public class ApplicationService {
.candidateAvatarUrl((String) row[8]) .candidateAvatarUrl((String) row[8])
.createdAt((LocalDateTime) row[9]) .createdAt((LocalDateTime) row[9])
.updatedAt((LocalDateTime) row[10]) .updatedAt((LocalDateTime) row[10])
.attemptNumber((Integer) row[11])
.previousApplicationId((Long) row[12])
.build()).collect(Collectors.toList()); .build()).collect(Collectors.toList());
return CandidateListResponse.builder() return CandidateListResponse.builder()
@ -349,6 +402,7 @@ public class ApplicationService {
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
public CandidateDetailView getCompanyCandidateDetail(Long companyId, Long applicationId) { public CandidateDetailView getCompanyCandidateDetail(Long companyId, Long applicationId) {
jobService.requireVerifiedCompany(companyId);
// 单次 JOIN 查询:applications + jobs + student_profiles + users // 单次 JOIN 查询:applications + jobs + student_profiles + users
// 查询结果包含岗位 companyId,数据隔离在 Service 层比较,避免额外的 findById 二次查询 // 查询结果包含岗位 companyId,数据隔离在 Service 层比较,避免额外的 findById 二次查询
TypedQuery<Object[]> query = em.createQuery( TypedQuery<Object[]> query = em.createQuery(
@ -389,6 +443,8 @@ public class ApplicationService {
return CandidateDetailView.builder() return CandidateDetailView.builder()
.applicationId(app.getId()) .applicationId(app.getId())
.attemptNumber(app.getAttemptNumber())
.previousApplicationId(app.getPreviousApplicationId())
.jobId(app.getJobId()) .jobId(app.getJobId())
.jobTitle(job.getTitle()) .jobTitle(job.getTitle())
.jobCity(job.getCity()) .jobCity(job.getCity())

4
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatus.java

@ -23,8 +23,8 @@ public enum ApplicationStatus {
public boolean canTransitionTo(ApplicationStatus target) { public boolean canTransitionTo(ApplicationStatus target) {
if (target == null) return false; if (target == null) return false;
return switch (this) { return switch (this) {
case SUBMITTED -> target == VIEWED || target == WITHDRAWN; case SUBMITTED -> target == VIEWED || target == REJECTED || target == WITHDRAWN;
case VIEWED -> target == INTERVIEW || target == WITHDRAWN; case VIEWED -> target == INTERVIEW || target == REJECTED || target == WITHDRAWN;
case INTERVIEW -> target == REJECTED || target == HIRED; case INTERVIEW -> target == REJECTED || target == HIRED;
case REJECTED, HIRED, WITHDRAWN -> false; // 终态不可再流转 case REJECTED, HIRED, WITHDRAWN -> false; // 终态不可再流转
}; };

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

4
mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java

@ -7,6 +7,7 @@ import com.mydreamjob.profile.StudentProfile;
import com.mydreamjob.profile.StudentProfileRepository; import com.mydreamjob.profile.StudentProfileRepository;
import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; import com.mydreamjob.profile.storage.ResumeFilenameSanitizer;
import com.mydreamjob.profile.storage.ResumeStorageService; import com.mydreamjob.profile.storage.ResumeStorageService;
import com.mydreamjob.profile.storage.ResumeOwnershipVerifier;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -39,6 +40,7 @@ public class CompanyResumeService {
private final CompanyResumeAccessPolicy accessPolicy; private final CompanyResumeAccessPolicy accessPolicy;
private final StudentProfileRepository profileRepository; private final StudentProfileRepository profileRepository;
private final ResumeStorageService resumeStorageService; private final ResumeStorageService resumeStorageService;
private final ResumeOwnershipVerifier resumeOwnership;
/** /**
* 读取该投递对应候选人的简历。 * 读取该投递对应候选人的简历。
@ -60,6 +62,8 @@ public class CompanyResumeService {
throw new BusinessException(ErrorCode.PROFILE_NOT_FOUND); throw new BusinessException(ErrorCode.PROFILE_NOT_FOUND);
} }
// 企业获得的是该候选人的读取授权,不能将历史污染引用当作文件归属证明。
resumeOwnership.assertOwnedBy(storagePath, decision.studentUserId());
byte[] content = resumeStorageService.read(storagePath); byte[] content = resumeStorageService.read(storagePath);
String filename = ResumeFilenameSanitizer.forDownload(profile.getResumeOriginalName(), "resume"); String filename = ResumeFilenameSanitizer.forDownload(profile.getResumeOriginalName(), "resume");
String contentType = resolveContentType(profile.getResumeContentType()); String contentType = resolveContentType(profile.getResumeContentType());

2
mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java

@ -24,6 +24,8 @@ public class CandidateDetailView {
private String jobCity; private String jobCity;
private String jobEmploymentType; private String jobEmploymentType;
private String status; private String status;
private Integer attemptNumber;
private Long previousApplicationId;
private String coverLetter; private String coverLetter;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;

2
mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java

@ -20,6 +20,8 @@ public class CandidateItemView {
private Long jobId; private Long jobId;
private String jobTitle; private String jobTitle;
private String status; private String status;
private Integer attemptNumber;
private Long previousApplicationId;
private String candidateName; private String candidateName;
private String candidateSchool; private String candidateSchool;
private String candidateEducation; private String candidateEducation;

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

@ -38,6 +38,7 @@ public class AuthService {
private final AccountDeletionPhoneHasher accountDeletionPhoneHasher; private final AccountDeletionPhoneHasher accountDeletionPhoneHasher;
private final AuthSessionService authSessionService; private final AuthSessionService authSessionService;
private final Clock clock; private final Clock clock;
private final com.mydreamjob.auth.password.PasswordSetupService passwordSetupService;
/** /**
* Mock 登录:验证短信验证码后自动创建/登录用户,并建立一次登录会话。 * Mock 登录:验证短信验证码后自动创建/登录用户,并建立一次登录会话。
@ -62,7 +63,8 @@ public class AuthService {
user = User.builder() user = User.builder()
.phone(phone) .phone(phone)
.nickname("用户_" + phone.substring(7)) .nickname("用户_" + phone.substring(7))
.role(User.Role.STUDENT) .role(User.Role.UNASSIGNED)
.roles(Set.of())
.active(true) .active(true)
.verified(true) .verified(true)
.build(); .build();
@ -97,6 +99,15 @@ public class AuthService {
result.put("phone", user.getPhone()); result.put("phone", user.getPhone());
result.put("nickname", user.getNickname()); result.put("nickname", user.getNickname());
result.put("role", user.getRole()); 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("accessToken", tokens.accessToken());
result.put("refreshToken", tokens.refreshToken()); result.put("refreshToken", tokens.refreshToken());
result.put("expiresIn", EXPIRES_IN_SECONDS); result.put("expiresIn", EXPIRES_IN_SECONDS);
@ -137,7 +148,9 @@ public class AuthService {
result.put("nickname", user.getNickname()); result.put("nickname", user.getNickname());
result.put("avatarUrl", user.getAvatarUrl()); result.put("avatarUrl", user.getAvatarUrl());
result.put("role", user.getRole()); result.put("role", user.getRole());
result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED);
result.put("active", user.getActive()); result.put("active", user.getActive());
result.put("passwordConfigured", user.getPasswordHash() != null);
result.put("verified", user.getVerified()); result.put("verified", user.getVerified());
result.put("createdAt", user.getCreatedAt()); result.put("createdAt", user.getCreatedAt());
return result; 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); 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); 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); 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 原子条件更新(返回值必须被严格检查)──────────────────── // ── 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") "AND c.attempts < c.maxAttempts")
int consumeIfValid(@Param("id") Long id, @Param("now") LocalDateTime now); 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); 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 RedisTemplate<String, Object> redisTemplate;
private final AuditService auditService; private final AuditService auditService;
private final SmsCodeSender smsCodeSender; private final SmsCodeSender smsCodeSender;
private final SmsFailedAttemptRecorder failedAttemptRecorder;
@Value("${app.sms.mock.code-length:6}") @Value("${app.sms.mock.code-length:6}")
private int codeLength; private int codeLength;
@ -73,9 +74,10 @@ public class SmsVerificationService {
throw new BusinessException(ErrorCode.SMS_RATE_LIMITED); throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
} }
// 检查频率限制(60秒内最多1次) // Redis SET NX EX 原子占位,防止并发请求同时越过发送频率限制。
String rateLimitKey = RATE_LIMIT_KEY_PREFIX + phone; 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)); log.warn("SMS send rejected: rate limited, phone={}", maskPhone(phone));
throw new BusinessException(ErrorCode.SMS_RATE_LIMITED); throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
} }
@ -96,9 +98,6 @@ public class SmsVerificationService {
.build(); .build();
codeRepository.save(smsCode); codeRepository.save(smsCode);
// 设置频率限制
redisTemplate.opsForValue().set(rateLimitKey, "1", 60, TimeUnit.SECONDS);
// 投递验证码:实现方保证不把验证码/完整手机号写入日志。 // 投递验证码:实现方保证不把验证码/完整手机号写入日志。
// 不可用实现会在此抛出 SERVICE_UNAVAILABLE,令本次事务回滚(不产生虚假的“发送成功”)。 // 不可用实现会在此抛出 SERVICE_UNAVAILABLE,令本次事务回滚(不产生虚假的“发送成功”)。
smsCodeSender.sendVerificationCode(phone, purpose, code); smsCodeSender.sendVerificationCode(phone, purpose, code);
@ -158,8 +157,7 @@ public class SmsVerificationService {
// 校验验证码值 // 校验验证码值
if (!code.equals(smsCode.getCode())) { if (!code.equals(smsCode.getCode())) {
smsCode.setAttempts(smsCode.getAttempts() + 1); failedAttemptRecorder.record(smsCode.getId(), now);
codeRepository.save(smsCode);
throw new BusinessException(ErrorCode.SMS_CODE_INVALID); 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; private LocalDateTime updatedAt;
public enum Role { public enum Role {
UNASSIGNED,
STUDENT, STUDENT,
COMPANY, COMPANY,
ADMIN 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.JpaRepository;
import org.springframework.data.jpa.repository.Lock; 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.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@ -14,6 +15,11 @@ import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByPhone(String phone); 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) @Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select u from User u where u.phone = :phone") @Query("select u from User u where u.phone = :phone")
Optional<User> findByPhoneForUpdate(@Param("phone") String 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 @Data
public static class CompanyDetails { 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 @NotBlank
@Size(max = 100) @Size(max = 100)
private String name; 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("phone", user.getPhone());
result.put("nickname", user.getNickname()); result.put("nickname", user.getNickname());
result.put("role", user.getRole()); result.put("role", user.getRole());
result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED);
result.put("passwordConfigured", true);
result.put("accessToken", tokens.accessToken()); result.put("accessToken", tokens.accessToken());
result.put("refreshToken", tokens.refreshToken()); result.put("refreshToken", tokens.refreshToken());
result.put("expiresIn", AuthService.EXPIRES_IN_SECONDS); result.put("expiresIn", AuthService.EXPIRES_IN_SECONDS);
@ -116,7 +118,8 @@ public class PasswordAuthenticationService {
return new PasswordOperationResult(true, true); 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) User user = userRepository.findByIdForUpdate(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.CREDENTIALS_INVALID, "手机号或密码错误")); .orElseThrow(() -> new BusinessException(ErrorCode.CREDENTIALS_INVALID, "手机号或密码错误"));
if (!usable(user)) { 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> * <ul>
* <li>{@code true} → {@link MockSmsCodeSender}(本地/测试环境,不调用外部网关)</li> * <li>{@code true} → {@link MockSmsCodeSender}(本地/测试环境,不调用外部网关)</li>
* <li>{@code false} 或未配置 → {@link UnavailableSmsCodeSender}(安全失败,不伪造成功)</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.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
/** /**
* 短信发送实现装配(单 Bean 方案)。 * 短信发送实现装配(单 Bean 方案,真实发送与 Mock 互斥)。
* *
* <p>由本配置类<strong>始终且仅</strong>产生一个 {@link SmsCodeSender},从而彻底避免 * <p>由本配置类<strong>始终且仅</strong>产生一个 {@link SmsCodeSender},从而彻底避免
* “两个互斥 {@code @ConditionalOnProperty} 在空值/空白值下都不匹配 → 0 个 Bean → * “两个互斥 {@code @ConditionalOnProperty} 在空值/空白值下都不匹配 → 0 个 Bean →
* 模糊 Spring 装配错误导致启动失败”的问题。</p> * 模糊 Spring 装配错误导致启动失败”的问题。</p>
* *
* <p>取值语义(大小写不敏感,两侧空白忽略):</p> * <p>显式启用 Aliyun 时必须具备完整配置,禁止同时启用 Mock;否则启动安全失败。
* 未启用 Aliyun 时,Mock 取值语义(大小写不敏感,两侧空白忽略):</p>
* <ul> * <ul>
* <li>{@code true} → {@link MockSmsCodeSender}</li> * <li>{@code true} → {@link MockSmsCodeSender}</li>
* <li>{@code false} / 缺失 / 空字符串 / 仅空白 / 其他非法值 → {@link UnavailableSmsCodeSender}</li> * <li>{@code false} / 缺失 / 空字符串 / 仅空白 / 其他非法值 → {@link UnavailableSmsCodeSender}</li>
@ -21,6 +23,7 @@ import org.springframework.context.annotation.Configuration;
* 既不启用 Mock,也不进入发送流程。本类不输出配置原值到日志。</p> * 既不启用 Mock,也不进入发送流程。本类不输出配置原值到日志。</p>
*/ */
@Configuration @Configuration
@EnableConfigurationProperties(AliyunSmsProperties.class)
public class SmsSenderConfiguration { public class SmsSenderConfiguration {
/** 显式启用 Mock 的唯一取值。 */ /** 显式启用 Mock 的唯一取值。 */
@ -28,8 +31,23 @@ public class SmsSenderConfiguration {
@Bean @Bean
public SmsCodeSender smsCodeSender( public SmsCodeSender smsCodeSender(
@Value("${app.sms.mock.enabled:false}") String rawEnabled) { @Value("${app.sms.mock.enabled:false}") String rawEnabled,
if (rawEnabled != null && MOCK_ENABLED_VALUE.equalsIgnoreCase(rawEnabled.trim())) { 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 MockSmsCodeSender();
} }
return new UnavailableSmsCodeSender(); 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、缺失、 * <p>由 {@link SmsSenderConfiguration} 在 {@code app.sms.mock.enabled} 为 false、缺失、
* 空值、仅空白或其他非法值时装配(不再使用条件注解,以保证任何取值下恰好一个 Bean)。</p> * 空值、仅空白或其他非法值时装配(不再使用条件注解,以保证任何取值下恰好一个 Bean)。</p>
* *
* <p>当前未接入真实短信网关,因此本实现<strong>安全失败</strong>: * <p>未显式启用真实短信网关或 Mock 时,本实现<strong>安全失败</strong>:
* 抛出既有的“服务不可用”业务异常,绝不返回虚假的“发送成功”。 * 抛出既有的“服务不可用”业务异常,绝不返回虚假的“发送成功”。
* 异常消息为固定安全文本,不含手机号、验证码或任何配置细节。</p> * 异常消息为固定安全文本,不含手机号、验证码或任何配置细节。</p>
*/ */

10
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java

@ -13,9 +13,17 @@ public interface ApplicationMatchRecordRepository extends JpaRepository<Applicat
Optional<ApplicationMatchRecord> findByApplicationId(Long applicationId); Optional<ApplicationMatchRecord> findByApplicationId(Long applicationId);
/** 撤回后的历史直聊只保留阅读,重新发送必须重新取得有效匹配。 */
@Query("SELECT COUNT(a) > 0 FROM Application a JOIN Job j ON j.id = a.jobId "
+ "WHERE j.companyId = :companyId AND a.studentUserId = :candidateId AND a.status = 'WITHDRAWN'")
boolean existsWithdrawnApplication(@Param("companyId") Long companyId,
@Param("candidateId") Long candidateId);
/** 企业(companyId)与候选人(candidateId)之间是否存在已 MATCHED 的投递(用于建会话守卫) */ /** 企业(companyId)与候选人(candidateId)之间是否存在已 MATCHED 的投递(用于建会话守卫) */
@Query("SELECT COUNT(r) > 0 FROM ApplicationMatchRecord r " @Query("SELECT COUNT(r) > 0 FROM ApplicationMatchRecord r "
+ "WHERE r.companyId = :companyId AND r.candidateId = :candidateId AND r.status = 'MATCHED'") + "JOIN Application a ON a.id = r.applicationId "
+ "WHERE r.companyId = :companyId AND r.candidateId = :candidateId AND r.status = 'MATCHED' "
+ "AND a.status NOT IN ('REJECTED', 'WITHDRAWN')")
boolean existsMatchedApplication(@Param("companyId") Long companyId, boolean existsMatchedApplication(@Param("companyId") Long companyId,
@Param("candidateId") Long candidateId); @Param("candidateId") Long candidateId);

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

9
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.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService; import com.mydreamjob.job.JobService;
import com.mydreamjob.messaging.Conversation; import com.mydreamjob.messaging.Conversation;
@ -43,6 +44,8 @@ public class MatchConversationService {
@Transactional @Transactional
public Map<String, Object> createOrGetMatchConversation(Long userId, Long applicationId) { 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) Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId()); Job job = jobService.getJob(app.getJobId());
@ -56,8 +59,7 @@ public class MatchConversationService {
.map(Company::getOwnerUserId) .map(Company::getOwnerUserId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
} else { } else {
Long actorCompanyId = companyRepository.findByOwnerUserId(userId) Long actorCompanyId = ownedCompany == null ? null : ownedCompany.getId();
.map(Company::getId).orElse(null);
if (actorCompanyId == null || !actorCompanyId.equals(companyId)) { if (actorCompanyId == null || !actorCompanyId.equals(companyId)) {
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
} }
@ -65,6 +67,9 @@ public class MatchConversationService {
} }
// 必须已 MATCHED // 必须已 MATCHED
if ("WITHDRAWN".equals(app.getStatus()) || "REJECTED".equals(app.getStatus())) {
throw new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED);
}
ApplicationMatchRecord record = recordRepository.findByApplicationId(applicationId).orElse(null); ApplicationMatchRecord record = recordRepository.findByApplicationId(applicationId).orElse(null);
if (record == null || !ApplicationMatchRecord.STATUS_MATCHED.equals(record.getStatus())) { if (record == null || !ApplicationMatchRecord.STATUS_MATCHED.equals(record.getStatus())) {
throw new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED); throw new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED);

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.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.security.UserRoleGuard; import com.mydreamjob.security.UserRoleGuard;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -33,6 +34,7 @@ public class MatchRuleService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Map<String, Object> getCompanyRule(Long userId) { public Map<String, Object> getCompanyRule(Long userId) {
Company company = requireCompany(userId); Company company = requireCompany(userId);
VerifiedCompanyGuard.requireVerified(company);
CompanyMatchRule rule = companyRuleRepository.findByCompanyId(company.getId()).orElse(null); CompanyMatchRule rule = companyRuleRepository.findByCompanyId(company.getId()).orElse(null);
int required = rule != null ? rule.getRequiredCandidateScore() int required = rule != null ? rule.getRequiredCandidateScore()
: CompanyMatchRule.DEFAULT_REQUIRED_CANDIDATE_SCORE; : CompanyMatchRule.DEFAULT_REQUIRED_CANDIDATE_SCORE;
@ -42,6 +44,7 @@ public class MatchRuleService {
@Transactional @Transactional
public Map<String, Object> updateCompanyRule(Long userId, int requiredCandidateScore) { public Map<String, Object> updateCompanyRule(Long userId, int requiredCandidateScore) {
Company company = requireCompany(userId); Company company = requireCompany(userId);
VerifiedCompanyGuard.requireVerified(company);
CompanyMatchRule rule = companyRuleRepository.findByCompanyId(company.getId()) CompanyMatchRule rule = companyRuleRepository.findByCompanyId(company.getId())
.orElseGet(() -> CompanyMatchRule.builder().companyId(company.getId()).build()); .orElseGet(() -> CompanyMatchRule.builder().companyId(company.getId()).build());
rule.setRequiredCandidateScore(requiredCandidateScore); 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.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService; import com.mydreamjob.job.JobService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -107,6 +108,7 @@ public class CandidateScoringEngineService {
public CompanyCandidateScoringResponse getCompanyScoring(Long companyUserId, Long applicationId) { public CompanyCandidateScoringResponse getCompanyScoring(Long companyUserId, Long applicationId) {
Company company = companyRepository.findByOwnerUserId(companyUserId) Company company = companyRepository.findByOwnerUserId(companyUserId)
.orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN)); .orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN));
VerifiedCompanyGuard.requireVerified(company);
Application app = applicationRepository.findById(applicationId) Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId()); 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.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService; import com.mydreamjob.job.JobService;
import com.mydreamjob.profile.StudentProfile; import com.mydreamjob.profile.StudentProfile;
@ -450,8 +451,9 @@ public class CompanyScoringDashboardService {
// ── 辅助 ──────────────────────────────────────────────────── // ── 辅助 ────────────────────────────────────────────────────
private Company requireCompany(Long userId) { private Company requireCompany(Long userId) {
return companyRepository.findByOwnerUserId(userId) Company company = companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN)); .orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN));
return VerifiedCompanyGuard.requireVerified(company);
} }
private void validatePage(int page, int size) { private void validatePage(int page, int size) {
if (page < 0 || size < 1 || size > 100) { if (page < 0 || size < 1 || size > 100) {

10
mydreamjob-server/src/main/java/com/mydreamjob/common/ProductBrand.java

@ -0,0 +1,10 @@
package com.mydreamjob.common;
/** 对外品牌统一入口;不改变包名、数据库、接口路径及部署标识。 */
public final class ProductBrand {
public static final String DISPLAY_NAME = "轻松聘";
public static final String API_TITLE = DISPLAY_NAME + " API";
public static final String API_DESCRIPTION = DISPLAY_NAME + " - 大学生求职服务平台 API";
private ProductBrand() { }
}

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

@ -37,9 +37,12 @@ public enum ErrorCode {
COMPANY_NOT_FOUND(5001, "company not found"), COMPANY_NOT_FOUND(5001, "company not found"),
COMPANY_OWNER_MISMATCH(5002, "company owner mismatch"), COMPANY_OWNER_MISMATCH(5002, "company owner mismatch"),
COMPANY_ALREADY_EXISTS(5003, "company already exists"), COMPANY_ALREADY_EXISTS(5003, "company already exists"),
COMPANY_NOT_VERIFIED(5004, "company not verified"),
// 学生资料 6xxx // 学生资料 6xxx
PROFILE_NOT_FOUND(6001, "profile not found"), PROFILE_NOT_FOUND(6001, "profile not found"),
ONLINE_RESUME_REVISION_CONFLICT(6002, "online resume changed; refresh before saving"),
ONLINE_RESUME_ENTRY_NOT_FOUND(6003, "online resume entry not found"),
// 岗位 7xxx // 岗位 7xxx
JOB_NOT_FOUND(7001, "job not found"), JOB_NOT_FOUND(7001, "job not found"),
@ -88,6 +91,7 @@ public enum ErrorCode {
MESSAGE_NOT_FOUND(8306, "message not found"), MESSAGE_NOT_FOUND(8306, "message not found"),
MESSAGE_RECALL_WINDOW_EXPIRED(8307, "message recall window expired"), MESSAGE_RECALL_WINDOW_EXPIRED(8307, "message recall window expired"),
MESSAGE_RECALL_CONCURRENT_UPDATE(8308, "message recall concurrent update"), MESSAGE_RECALL_CONCURRENT_UPDATE(8308, "message recall concurrent update"),
RESUME_MESSAGE_UNAVAILABLE(8309, "resume message is no longer available"),
// 评价 91xx // 评价 91xx
DUPLICATE_REVIEW(9101, "duplicate review"), DUPLICATE_REVIEW(9101, "duplicate review"),
@ -239,7 +243,22 @@ public enum ErrorCode {
COMPANY_REGISTRATION_DISABLED(10301, "company registration is disabled"), COMPANY_REGISTRATION_DISABLED(10301, "company registration is disabled"),
COMPANY_REGISTRATION_CONFLICT(10302, "company registration conflict"), COMPANY_REGISTRATION_CONFLICT(10302, "company registration conflict"),
COMPANY_ONBOARDING_INCONSISTENT_STATE(10303, "company onboarding state is inconsistent"), 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 int code;
private final String message; private final String message;

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

@ -111,11 +111,16 @@ public final class ErrorHttpStatusRegistry {
return new Builder() return new Builder()
.put(HttpStatus.FORBIDDEN, .put(HttpStatus.FORBIDDEN,
ErrorCode.APPLICATION_OWNER_MISMATCH, ErrorCode.APPLICATION_OWNER_MISMATCH,
ErrorCode.COMPANY_NOT_VERIFIED,
ErrorCode.IDENTITY_SELECTION_REQUIRED,
ErrorCode.FORBIDDEN, ErrorCode.FORBIDDEN,
ErrorCode.INTERVIEW_ACCESS_DENIED, ErrorCode.INTERVIEW_ACCESS_DENIED,
ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION, ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION,
ErrorCode.OFFER_COMPARE_NOT_OWNER) ErrorCode.OFFER_COMPARE_NOT_OWNER)
.put(HttpStatus.NOT_FOUND, .put(HttpStatus.NOT_FOUND,
ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND,
ErrorCode.SALARY_CITY_NOT_FOUND,
ErrorCode.SALARY_HISTORY_NOT_FOUND,
ErrorCode.ABILITY_ATTEMPT_NOT_FOUND, ErrorCode.ABILITY_ATTEMPT_NOT_FOUND,
ErrorCode.ABILITY_RESULT_NOT_FOUND, ErrorCode.ABILITY_RESULT_NOT_FOUND,
ErrorCode.ABILITY_TYPE_NOT_FOUND, ErrorCode.ABILITY_TYPE_NOT_FOUND,
@ -133,6 +138,7 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND, ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND,
ErrorCode.OFFER_NOT_FOUND, ErrorCode.OFFER_NOT_FOUND,
ErrorCode.PROFILE_NOT_FOUND, ErrorCode.PROFILE_NOT_FOUND,
ErrorCode.ONLINE_RESUME_ENTRY_NOT_FOUND,
ErrorCode.REPORT_NOT_FOUND, ErrorCode.REPORT_NOT_FOUND,
ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND, ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND,
ErrorCode.RESUME_VERSION_NOT_FOUND, ErrorCode.RESUME_VERSION_NOT_FOUND,
@ -144,7 +150,16 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.USER_NOT_FOUND, ErrorCode.USER_NOT_FOUND,
ErrorCode.VERIFICATION_NOT_FOUND) ErrorCode.VERIFICATION_NOT_FOUND)
.put(HttpStatus.CONFLICT, .put(HttpStatus.CONFLICT,
ErrorCode.ONLINE_RESUME_REVISION_CONFLICT,
ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE,
ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED,
ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT,
ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE,
ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE,
ErrorCode.SALARY_IDEMPOTENCY_CONFLICT,
ErrorCode.SALARY_POLICY_VERSION_CONFLICT,
ErrorCode.PASSWORD_STATE_CONFLICT, ErrorCode.PASSWORD_STATE_CONFLICT,
ErrorCode.IDENTITY_SELECTION_CONFLICT,
ErrorCode.ABILITY_ATTEMPT_INVALID_STATE, ErrorCode.ABILITY_ATTEMPT_INVALID_STATE,
ErrorCode.ABILITY_CONCURRENT_UPDATE, ErrorCode.ABILITY_CONCURRENT_UPDATE,
ErrorCode.ABILITY_DUPLICATE_ACTIVE, ErrorCode.ABILITY_DUPLICATE_ACTIVE,
@ -192,6 +207,7 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.MATCH_SCORES_LOCKED, ErrorCode.MATCH_SCORES_LOCKED,
ErrorCode.MESSAGE_RECALL_CONCURRENT_UPDATE, ErrorCode.MESSAGE_RECALL_CONCURRENT_UPDATE,
ErrorCode.MESSAGE_RECALL_WINDOW_EXPIRED, ErrorCode.MESSAGE_RECALL_WINDOW_EXPIRED,
ErrorCode.RESUME_MESSAGE_UNAVAILABLE,
ErrorCode.OFFER_COUNTER_OFFER_EXISTS, ErrorCode.OFFER_COUNTER_OFFER_EXISTS,
ErrorCode.OFFER_COUNTER_OFFER_EXPIRED, ErrorCode.OFFER_COUNTER_OFFER_EXPIRED,
ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING, ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING,
@ -226,6 +242,7 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE) ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE)
.put(HttpStatus.SERVICE_UNAVAILABLE, .put(HttpStatus.SERVICE_UNAVAILABLE,
ErrorCode.COMPANY_REGISTRATION_DISABLED, ErrorCode.COMPANY_REGISTRATION_DISABLED,
ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE,
ErrorCode.COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE) ErrorCode.COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE)
.put(HttpStatus.TOO_MANY_REQUESTS, .put(HttpStatus.TOO_MANY_REQUESTS,
ErrorCode.PASSWORD_RATE_LIMITED, 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; package com.mydreamjob.common.exception;
import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.salarycalculator.SalaryPolicyVersionConflictException;
import com.mydreamjob.common.logging.RequestIdAccessor; import com.mydreamjob.common.logging.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException; import jakarta.validation.ConstraintViolationException;
@ -39,6 +40,20 @@ public class GlobalExceptionHandler {
private final RequestIdAccessor requestIdAccessor; private final RequestIdAccessor requestIdAccessor;
private final ErrorHttpStatusRegistry errorHttpStatusRegistry; private final ErrorHttpStatusRegistry errorHttpStatusRegistry;
public record SalaryPolicyConflictDetails(String latestPolicyVersion,
java.time.LocalDate policyEffectiveDate) {}
@ExceptionHandler(SalaryPolicyVersionConflictException.class)
public ResponseEntity<ApiResponse<SalaryPolicyConflictDetails>> handleSalaryPolicyConflict(
SalaryPolicyVersionConflictException ex, HttpServletRequest request) {
ErrorCode code = ErrorCode.SALARY_POLICY_VERSION_CONFLICT;
ApiResponse<SalaryPolicyConflictDetails> body = ApiResponse.error(
code.getCode(), code.getMessage(), getRequestId(request));
body.setData(new SalaryPolicyConflictDetails(
ex.getLatestPolicyVersion(), ex.getLatestPolicyEffectiveDate()));
return ResponseEntity.status(errorHttpStatusRegistry.resolve(code.getCode())).body(body);
}
@ExceptionHandler(ConstraintViolationException.class) @ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleConstraintViolation( public ResponseEntity<ApiResponse<Void>> handleConstraintViolation(
ConstraintViolationException ex, HttpServletRequest request) { ConstraintViolationException ex, HttpServletRequest request) {

3
mydreamjob-server/src/main/java/com/mydreamjob/company/Company.java

@ -42,6 +42,9 @@ public class Company {
@Column(name = "industry", length = 80) @Column(name = "industry", length = 80)
private String industry; private String industry;
@Column(name = "company_size", length = 40)
private String companySize;
@Column(name = "website", length = 255) @Column(name = "website", length = 255)
private String website; private String website;

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.CompanyPublicView;
import com.mydreamjob.company.dto.CompanyUpdateRequest; import com.mydreamjob.company.dto.CompanyUpdateRequest;
import com.mydreamjob.common.api.ApiResponse; 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 com.mydreamjob.job.dto.JobSearchItem;
import io.swagger.v3.oas.annotations.Operation; 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 io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; 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 岗位") @Operation(summary = "查询企业公开岗位列表", description = "匿名可访问,仅返回 PUBLISHED 岗位")
@GetMapping("/{companyId}/jobs") @GetMapping("/{companyId}/jobs")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyJobs( public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyJobs(

44
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.CompanyPublicView;
import com.mydreamjob.company.dto.CompanyUpdateRequest; import com.mydreamjob.company.dto.CompanyUpdateRequest;
import com.mydreamjob.job.JobService; import com.mydreamjob.job.JobService;
import com.mydreamjob.job.dto.CompanyManagedJobItem;
import com.mydreamjob.job.dto.JobSearchItem; import com.mydreamjob.job.dto.JobSearchItem;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@ -74,6 +75,7 @@ public class CompanyService {
.logoUrl(req.getLogoUrl()) .logoUrl(req.getLogoUrl())
.description(req.getDescription()) .description(req.getDescription())
.industry(req.getIndustry()) .industry(req.getIndustry())
.companySize(normalizeCompanySize(req.getCompanySize()))
.website(req.getWebsite()) .website(req.getWebsite())
.address(req.getAddress()) .address(req.getAddress())
.verified(false) .verified(false)
@ -94,12 +96,43 @@ public class CompanyService {
if (req.getLogoUrl() != null) company.setLogoUrl(req.getLogoUrl()); if (req.getLogoUrl() != null) company.setLogoUrl(req.getLogoUrl());
if (req.getDescription() != null) company.setDescription(req.getDescription()); if (req.getDescription() != null) company.setDescription(req.getDescription());
if (req.getIndustry() != null) company.setIndustry(req.getIndustry()); if (req.getIndustry() != null) company.setIndustry(req.getIndustry());
// companySize:null/缺省保持原值;其余交给与创建共用的同一套归一化规则。
if (req.getCompanySize() != null) {
company.setCompanySize(normalizeCompanySize(req.getCompanySize()));
}
if (req.getWebsite() != null) company.setWebsite(req.getWebsite()); if (req.getWebsite() != null) company.setWebsite(req.getWebsite());
if (req.getAddress() != null) company.setAddress(req.getAddress()); if (req.getAddress() != null) company.setAddress(req.getAddress());
return companyRepository.save(company); return companyRepository.save(company);
} }
/** 企业规模长度上限,与请求 DTO 的 @Size(max = 40) 保持一致。 */
private static final int MAX_COMPANY_SIZE_LENGTH = 40;
/**
* companySize 统一归一化——创建与更新**共用同一套规则**,避免两套逻辑漂移。
*
* <ul>
* <li>{@code null} / 缺省 → {@code null}(创建即存 SQL NULL;更新由调用方先判 null 以「保持原值」)</li>
* <li>空串或纯空白 → {@code null}(清空)</li>
* <li>非空 → 去掉两端空白后保存(如 {@code " 100-499人 "} → {@code "100-499人"})</li>
* <li>超过 40 字符 → 抛参数错误,**不截断**</li>
* </ul>
*/
private static String normalizeCompanySize(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
if (trimmed.isEmpty()) {
return null;
}
if (trimmed.length() > MAX_COMPANY_SIZE_LENGTH) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
return trimmed;
}
/** /**
* 将企业 Entity 转为 API 视图(排除敏感字段) * 将企业 Entity 转为 API 视图(排除敏感字段)
*/ */
@ -112,6 +145,7 @@ public class CompanyService {
view.put("logoUrl", company.getLogoUrl()); view.put("logoUrl", company.getLogoUrl());
view.put("description", company.getDescription()); view.put("description", company.getDescription());
view.put("industry", company.getIndustry()); view.put("industry", company.getIndustry());
view.put("companySize", company.getCompanySize());
view.put("website", company.getWebsite()); view.put("website", company.getWebsite());
view.put("address", company.getAddress()); view.put("address", company.getAddress());
view.put("verified", company.getVerified()); view.put("verified", company.getVerified());
@ -142,6 +176,7 @@ public class CompanyService {
.logoUrl(company.getLogoUrl()) .logoUrl(company.getLogoUrl())
.description(company.getDescription()) .description(company.getDescription())
.industry(company.getIndustry()) .industry(company.getIndustry())
.companySize(company.getCompanySize())
.verified(company.getVerified()) .verified(company.getVerified())
.build(); .build();
} }
@ -167,4 +202,13 @@ public class CompanyService {
size // size 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());
}
}
}

8
mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyCreateRequest.java

@ -1,5 +1,6 @@
package com.mydreamjob.company.dto; package com.mydreamjob.company.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.Data; import lombok.Data;
@ -13,6 +14,13 @@ public class CompanyCreateRequest {
private String logoUrl; private String logoUrl;
private String description; private String description;
private String industry; private String industry;
@Size(max = 40, message = "company size must be at most 40 characters")
@Schema(types = {"string", "null"}, maxLength = 40,
description = "企业规模,≤40 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;"
+ "非空去两端空白后保存;超过 40 字符拒绝且不截断。")
private String companySize;
private String website; private String website;
private String address; private String address;
} }

19
mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyPublicView.java

@ -1,6 +1,7 @@
package com.mydreamjob.company.dto; package com.mydreamjob.company.dto;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
@ -8,19 +9,35 @@ import lombok.NoArgsConstructor;
/** /**
* 企业公开信息 DTO。 * 企业公开信息 DTO。
* 仅暴露 6 个公开字段,不泄露 ownerUserId、联系方式、审核信息等。 * 仅暴露 7 个公开字段,不泄露 ownerUserId、联系方式、审核信息等。
*/ */
@Data @Data
@Builder @Builder
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(name = "CompanyPublicView",
description = "企业公开摘要;严格 7 字段,不含 ownerUserId、联系方式、登记地址与审核信息")
public class CompanyPublicView { public class CompanyPublicView {
@Schema(description = "企业 ID")
private Long id; private Long id;
@Schema(description = "企业名称")
private String name; private String name;
@Schema(description = "企业 Logo")
private String logoUrl; private String logoUrl;
@Schema(description = "企业简介")
private String description; private String description;
@Schema(description = "所属行业")
private String industry; private String industry;
@Schema(types = {"string", "null"}, maxLength = 40, description = "企业规模,≤40 字符")
private String companySize;
@Schema(description = "是否已认证")
private Boolean verified; private Boolean verified;
} }

7
mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyUpdateRequest.java

@ -1,5 +1,6 @@
package com.mydreamjob.company.dto; package com.mydreamjob.company.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.Data; import lombok.Data;
@ -22,6 +23,12 @@ public class CompanyUpdateRequest {
@Size(max = 80, message = "industry must be at most 80 characters") @Size(max = 80, message = "industry must be at most 80 characters")
private String industry; private String industry;
@Size(max = 40, message = "company size must be at most 40 characters")
@Schema(types = {"string", "null"}, maxLength = 40,
description = "企业规模,≤40 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;"
+ "非空去两端空白后保存;超过 40 字符拒绝且不截断。")
private String companySize;
@Size(max = 255, message = "website must be at most 255 characters") @Size(max = 255, message = "website must be at most 255 characters")
private String website; private String website;

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

2
mydreamjob-server/src/main/java/com/mydreamjob/config/OpenApiSecurityConfig.java

@ -26,7 +26,7 @@ public class OpenApiSecurityConfig {
public OpenAPI homepageOpenAPI() { public OpenAPI homepageOpenAPI() {
return new OpenAPI() return new OpenAPI()
.info(new Info() .info(new Info()
.title("职来 API") .title(com.mydreamjob.common.ProductBrand.API_TITLE)
.version("v1")); .version("v1"));
} }
} }

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

@ -1,6 +1,8 @@
package com.mydreamjob.config; package com.mydreamjob.config;
import com.mydreamjob.auth.JwtAuthenticationFilter; import com.mydreamjob.auth.JwtAuthenticationFilter;
import com.mydreamjob.auth.identity.IdentityAccessFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
@ -36,6 +38,14 @@ public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final AuditService auditService; 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}") @Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:3000}")
private String allowedOrigins; private String allowedOrigins;
@ -73,6 +83,8 @@ public class SecurityConfig {
// 健康检查 API // 健康检查 API
.requestMatchers("/health/**").permitAll() .requestMatchers("/health/**").permitAll()
.requestMatchers("/health").permitAll() .requestMatchers("/health").permitAll()
// 仅放行握手;STOMP CONNECT 和订阅由实时通道独立鉴权。
.requestMatchers(HttpMethod.GET, "/ws").permitAll()
// 平台维护的岗位推荐分类字典(公开只读) // 平台维护的岗位推荐分类字典(公开只读)
.requestMatchers(HttpMethod.GET, "/job-recommendation-taxonomies").permitAll() .requestMatchers(HttpMethod.GET, "/job-recommendation-taxonomies").permitAll()
// 认证相关 // 认证相关
@ -90,6 +102,7 @@ public class SecurityConfig {
// S02:登出必须已认证 // S02:登出必须已认证
.requestMatchers("/auth/logout").authenticated() .requestMatchers("/auth/logout").authenticated()
.requestMatchers("/auth/password/set").authenticated() .requestMatchers("/auth/password/set").authenticated()
.requestMatchers("/auth/password/setup").authenticated()
.requestMatchers("/auth/password/change").authenticated() .requestMatchers("/auth/password/change").authenticated()
// 企业资料(需登录) // 企业资料(需登录)
.requestMatchers("/companies/me/**").authenticated() .requestMatchers("/companies/me/**").authenticated()
@ -154,7 +167,8 @@ public class SecurityConfig {
.requestMatchers("/").permitAll() .requestMatchers("/").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(identityAccessFilter, JwtAuthenticationFilter.class);
return http.build(); return http.build();
} }
@ -170,7 +184,8 @@ public class SecurityConfig {
config.setMaxAge(3600L); config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config); // 匹配的是去掉 servlet context-path 后的应用路径,来源仍由白名单限定。
source.registerCorsConfiguration("/**", config);
return source; return source;
} }

3
mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java

@ -31,8 +31,9 @@ public class ApiController {
public ResponseEntity<ApiResponse<Map<String, Object>>> index(HttpServletRequest request) { public ResponseEntity<ApiResponse<Map<String, Object>>> index(HttpServletRequest request) {
Map<String, Object> info = Map.of( Map<String, Object> info = Map.of(
"name", "mydreamjob", "name", "mydreamjob",
"displayName", com.mydreamjob.common.ProductBrand.DISPLAY_NAME,
"version", "0.1.0", "version", "0.1.0",
"description", "职来 - 大学生求职服务平台 API", "description", com.mydreamjob.common.ProductBrand.API_DESCRIPTION,
"endpoints", Map.of( "endpoints", Map.of(
"health", "GET /api/v1/health", "health", "GET /api/v1/health",
"info", "GET /api/v1/info", "info", "GET /api/v1/info",

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.CompanyRegistrationRequest;
import com.mydreamjob.auth.company.CompanyRegistrationResponse; import com.mydreamjob.auth.company.CompanyRegistrationResponse;
import com.mydreamjob.auth.company.CompanyRegistrationService; 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.audit.AuditContext;
import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
@ -55,6 +58,44 @@ public class AuthController {
private final CurrentUserAccessor currentUserAccessor; private final CurrentUserAccessor currentUserAccessor;
private final PasswordAuthenticationService passwordAuthenticationService; private final PasswordAuthenticationService passwordAuthenticationService;
private final CompanyRegistrationService companyRegistrationService; 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 短信验证码创建企业账号和企业资料") @Operation(summary = "企业注册", description = "使用 company-register 短信验证码创建企业账号和企业资料")
@PostMapping("/company/register") @PostMapping("/company/register")
@ -64,31 +105,21 @@ public class AuthController {
return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request))); return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request)));
} }
@Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。") @Operation(summary = "发送短信验证码", description = "真实发送使用阿里云短信网关;Mock 仅供开发测试。网关受理不等于手机送达,验证码不会写入日志。")
@PostMapping("/sms/send") @PostMapping("/sms/send")
public ResponseEntity<ApiResponse<Map<String, String>>> sendSmsCode( public ResponseEntity<ApiResponse<Map<String, String>>> sendSmsCode(
@RequestParam @NotBlank(message = "phone is required") @RequestParam @NotBlank(message = "phone is required")
@Pattern(regexp = "[0-9]{11}", message = "phone must contain exactly 11 digits") String phone, @Pattern(regexp = "[0-9]{11}", message = "phone must contain exactly 11 digits") String phone,
@RequestParam(defaultValue = "login") String purpose, @RequestParam(defaultValue = "login") String purpose,
HttpServletRequest request) { HttpServletRequest request) {
try {
smsVerificationService.sendCode(phone, purpose, smsVerificationService.sendCode(phone, purpose,
requestIdAccessor.get(request), requestIdAccessor.get(request), request.getRemoteAddr(), request.getHeader("User-Agent"));
request.getRemoteAddr(), // 保留业务码,统一走异常映射;不把 SDK 或 SQL 原始异常信息写回客户端。
request.getHeader("User-Agent")); return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "验证码发送请求已受理"),
return ResponseEntity.ok(ApiResponse.ok(
Map.of("message", "验证码已发送(Mock)"),
requestIdAccessor.get(request))); 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") @PostMapping("/login")
public ResponseEntity<ApiResponse<Map<String, Object>>> mockLogin( public ResponseEntity<ApiResponse<Map<String, Object>>> mockLogin(
@Valid @RequestBody LoginRequest body, @Valid @RequestBody LoginRequest body,
@ -112,8 +143,8 @@ public class AuthController {
return ResponseEntity.badRequest().body( return ResponseEntity.badRequest().body(
ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request))); ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request)));
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.badRequest().body( return ResponseEntity.status(503).body(
ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request))); 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))); 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( return ResponseEntity.status(401).body(
ApiResponse.unauthorized("invalid or expired token", requestIdAccessor.get(request))); ApiResponse.unauthorized("invalid or expired token", requestIdAccessor.get(request)));
} }

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

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

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

@ -5,6 +5,7 @@ import com.mydreamjob.auth.AuthSessionService;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.dataexport.PersonalDataExportCleanupService; import com.mydreamjob.dataexport.PersonalDataExportCleanupService;
import com.mydreamjob.salarycalculator.SalaryHistoryCleanupService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.orm.ObjectOptimisticLockingFailureException;
@ -35,8 +36,11 @@ public class AccountDeletionCompletionWorker {
private final AuditService auditService; private final AuditService auditService;
private final AccountDeletionPhoneHasher phoneHasher; private final AccountDeletionPhoneHasher phoneHasher;
private final PersonalDataExportCleanupService dataExportCleanupService; private final PersonalDataExportCleanupService dataExportCleanupService;
private final SalaryHistoryCleanupService salaryHistoryCleanupService;
private final AuthSessionService authSessionService; private final AuthSessionService authSessionService;
private final Clock clock; private final Clock clock;
private final com.mydreamjob.messaging.ChatResumeLifecycle chatResumeLifecycle;
private final com.mydreamjob.onlineresume.OnlineResumeRepository onlineResumeRepository;
/** /**
* 若用户处于 PENDING 且已到期,则执行完成态迁移 + 脱敏 + 审计。 * 若用户处于 PENDING 且已到期,则执行完成态迁移 + 脱敏 + 审计。
@ -68,6 +72,9 @@ public class AccountDeletionCompletionWorker {
rec.getUserId(), AuthSessionService.REASON_ACCOUNT_DELETED, capturedNow); rec.getUserId(), AuthSessionService.REASON_ACCOUNT_DELETED, capturedNow);
// 注销完成:清理该用户的个人导出文件并作废未完成任务 // 注销完成:清理该用户的个人导出文件并作废未完成任务
dataExportCleanupService.purgeForDeletedUser(rec.getUserId()); dataExportCleanupService.purgeForDeletedUser(rec.getUserId());
salaryHistoryCleanupService.purgeForDeletedUser(rec.getUserId());
chatResumeLifecycle.purgeForUser(rec.getUserId());
onlineResumeRepository.deleteById(rec.getUserId());
// 完成 + 脱敏审计各写一次 // 完成 + 脱敏审计各写一次
auditService.log(userId, "ACCOUNT_DELETION_COMPLETED", "ACCOUNT_DELETION", auditService.log(userId, "ACCOUNT_DELETION_COMPLETED", "ACCOUNT_DELETION",
rec.getId(), "account deletion completed; userId=" + userId rec.getId(), "account deletion completed; userId=" + userId

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

39
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.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.interview.dto.InterviewCancelRequest; import com.mydreamjob.interview.dto.InterviewCancelRequest;
import com.mydreamjob.interview.dto.InterviewCreateRequest; import com.mydreamjob.interview.dto.InterviewCreateRequest;
import com.mydreamjob.interview.dto.InterviewRespondRequest; import com.mydreamjob.interview.dto.InterviewRespondRequest;
@ -60,6 +61,7 @@ public class InterviewService {
public Interview createInterview(Long operatorUserId, Long applicationId, InterviewCreateRequest req, public Interview createInterview(Long operatorUserId, Long applicationId, InterviewCreateRequest req,
AuditContext ctx) { AuditContext ctx) {
Company company = requireCompany(operatorUserId); Company company = requireCompany(operatorUserId);
VerifiedCompanyGuard.requireVerified(company);
Application app = applicationRepository.findById(applicationId) Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
@ -68,6 +70,10 @@ public class InterviewService {
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
} }
if ("REJECTED".equals(app.getStatus()) || "WITHDRAWN".equals(app.getStatus())) {
throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION);
}
validateTimeRange(req.getStartAt(), req.getEndAt()); validateTimeRange(req.getStartAt(), req.getEndAt());
validateTypeFields(req.getInterviewType(), req.getMeetingUrl(), req.getAddress()); validateTypeFields(req.getInterviewType(), req.getMeetingUrl(), req.getAddress());
@ -101,6 +107,7 @@ public class InterviewService {
// ── 查询某投递的面试列表 ───────────────────────────────────── // ── 查询某投递的面试列表 ─────────────────────────────────────
public List<Interview> listByApplication(Long applicationId, Long userId) { public List<Interview> listByApplication(Long applicationId, Long userId) {
requireVerifiedIfCompany(userId);
Application app = applicationRepository.findById(applicationId) Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
assertApplicationAccess(app, userId); assertApplicationAccess(app, userId);
@ -119,6 +126,7 @@ public class InterviewService {
boolean hasRange = from != null && to != null; boolean hasRange = from != null && to != null;
if (hasRole(user, User.Role.COMPANY)) { if (hasRole(user, User.Role.COMPANY)) {
VerifiedCompanyGuard.requireVerified(requireCompany(userId));
return listByCompanyUser(userId, status, from, to, pr); return listByCompanyUser(userId, status, from, to, pr);
} }
if (hasRole(user, User.Role.STUDENT)) { if (hasRole(user, User.Role.STUDENT)) {
@ -158,6 +166,7 @@ public class InterviewService {
// ── 查看面试详情 ──────────────────────────────────────────── // ── 查看面试详情 ────────────────────────────────────────────
public Interview getInterview(Long interviewId, Long userId) { public Interview getInterview(Long interviewId, Long userId) {
requireVerifiedIfCompany(userId);
Interview interview = requireInterview(interviewId); Interview interview = requireInterview(interviewId);
assertViewAccess(interview, userId); assertViewAccess(interview, userId);
return interview; return interview;
@ -169,10 +178,12 @@ public class InterviewService {
public Interview updateInterview(Long operatorUserId, Long interviewId, InterviewUpdateRequest req, public Interview updateInterview(Long operatorUserId, Long interviewId, InterviewUpdateRequest req,
AuditContext ctx) { AuditContext ctx) {
Company company = requireCompany(operatorUserId); Company company = requireCompany(operatorUserId);
VerifiedCompanyGuard.requireVerified(company);
Interview interview = requireInterview(interviewId); Interview interview = requireInterview(interviewId);
if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) { if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) {
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
} }
requireOpenApplication(interview);
InterviewStatus current = InterviewStatus.valueOf(interview.getStatus()); InterviewStatus current = InterviewStatus.valueOf(interview.getStatus());
if (current != InterviewStatus.SCHEDULED && current != InterviewStatus.CONFIRMED) { if (current != InterviewStatus.SCHEDULED && current != InterviewStatus.CONFIRMED) {
@ -218,6 +229,7 @@ public class InterviewService {
InterviewStatus current = InterviewStatus.valueOf(interview.getStatus()); InterviewStatus current = InterviewStatus.valueOf(interview.getStatus());
InterviewStatus target = mapAction(req.getAction()); InterviewStatus target = mapAction(req.getAction());
if (target != InterviewStatus.DECLINED) requireOpenApplication(interview);
if (current == target) { if (current == target) {
return interview; // 幂等 return interview; // 幂等
} }
@ -253,6 +265,7 @@ public class InterviewService {
public Interview cancelInterview(Long operatorUserId, Long interviewId, InterviewCancelRequest req, public Interview cancelInterview(Long operatorUserId, Long interviewId, InterviewCancelRequest req,
AuditContext ctx) { AuditContext ctx) {
Company company = requireCompany(operatorUserId); Company company = requireCompany(operatorUserId);
VerifiedCompanyGuard.requireVerified(company);
Interview interview = requireInterview(interviewId); Interview interview = requireInterview(interviewId);
if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) { if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) {
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
@ -297,10 +310,12 @@ public class InterviewService {
@Transactional @Transactional
public Interview completeInterview(Long operatorUserId, Long interviewId, String notes, AuditContext ctx) { public Interview completeInterview(Long operatorUserId, Long interviewId, String notes, AuditContext ctx) {
Company company = requireCompany(operatorUserId); Company company = requireCompany(operatorUserId);
VerifiedCompanyGuard.requireVerified(company);
Interview interview = requireInterview(interviewId); Interview interview = requireInterview(interviewId);
if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) { if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) {
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
} }
requireOpenApplication(interview);
InterviewStatus current = InterviewStatus.valueOf(interview.getStatus()); InterviewStatus current = InterviewStatus.valueOf(interview.getStatus());
if (current == InterviewStatus.COMPLETED) { if (current == InterviewStatus.COMPLETED) {
@ -349,6 +364,13 @@ public class InterviewService {
// ── 视图 ──────────────────────────────────────────────────── // ── 视图 ────────────────────────────────────────────────────
private void requireOpenApplication(Interview interview) {
Application application = applicationRepository.findById(interview.getApplicationId())
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
if ("REJECTED".equals(application.getStatus()) || "WITHDRAWN".equals(application.getStatus()))
throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION);
}
public Map<String, Object> toView(Interview interview) { public Map<String, Object> toView(Interview interview) {
Map<String, Object> v = new HashMap<>(); Map<String, Object> v = new HashMap<>();
v.put("id", interview.getId()); v.put("id", interview.getId());
@ -411,17 +433,30 @@ public class InterviewService {
private void assertViewAccess(Interview interview, Long userId) { private void assertViewAccess(Interview interview, Long userId) {
User user = loadUser(userId); User user = loadUser(userId);
if (hasRole(user, User.Role.STUDENT) && interview.getStudentUserId().equals(userId)) return; 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); 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) { private void assertApplicationAccess(Application app, Long userId) {
User user = loadUser(userId); User user = loadUser(userId);
if (hasRole(user, User.Role.STUDENT) && app.getStudentUserId().equals(userId)) return; if (hasRole(user, User.Role.STUDENT) && app.getStudentUserId().equals(userId)) return;
if (hasRole(user, User.Role.COMPANY)) { if (hasRole(user, User.Role.COMPANY)) {
Job job = jobService.getJob(app.getJobId()); Job job = jobService.getJob(app.getJobId());
Company company = companyRepository.findByOwnerUserId(userId).orElse(null); 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); throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
} }

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

Loading…
Cancel
Save