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): 补充分页和可选摘要契约回归 6 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. 13727
      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. 71
      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`
青核项目不属于本仓库,禁止将其源代码提交到职来仓库。
青核项目不属于本仓库,禁止将其源代码提交到轻松聘仓库。

2
backend-infra/README.md

@ -1,4 +1,4 @@
# 职来本地后端依赖
# 轻松聘本地后端依赖
此目录仅用于本地开发的 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 基线

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)。下方历史规划不再用于判断当前待办或批准新接口/迁移。
@ -6,6 +6,7 @@
| 范围 | 2026-09-08核对结果 | 下一动作 |
|---|---|---|
| N03/N04/N05 模块集成(2026-10-08 增补) | 隔离集成版本全量 `mvn clean verify` 3224/3224,原仓库转入复验 227/227,F/E/S 均为 0;真实 MySQL 独立功能 9/9、迁移及运行时接口集合 2/2;N05 并发幂等阻断已修复;OpenAPI 196/239 | 待独立提交,随后安排部署和前后端联调;薪资政策数据发布前仍需来源复核。下方历史规划原文保留,不作为当前模块进度依据 |
| 核心招聘与账户能力 | 已有代码与历史验收记录;本轮未重跑验收 | 按清单C01–C08做一期集成回归 |
| V39–V42 | 已核对提交55e3975、2698d06、5cc2c86、96b3113;不再标为待Git检查点 | 保留回归,不重复开发企业回复/发现/邀约 |
| V43 | 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(cf39737) | N01 已关闭版本内小样本补集泄露;30/300/3000 查询预算通过,EXPLAIN 扩容风险保留观察 |
@ -30,7 +31,7 @@
>
> 确认日期:2026-08-25
>
> 适用项目:职来(mydreamjob-server)
> 适用项目:轻松聘(mydreamjob-server)
> **正式接口文档目录**:`C:\Users\15642\Desktop\zhilai\docs`(OpenAPI、前端联调、验收、各模块 API 文档的唯一正式目录)。
> 本文档 `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
@ -9,6 +9,17 @@
所有接口统一前缀 `/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)
先启动基础设施容器:
@ -160,8 +171,8 @@ POST /api/v1/auth/logout header: Authorization: Bearer <accessToken> (
| 401 | 401(安全过滤器) | 未登录 / 无 token |
| 403 | 2002, 8004, 8303, 8402 | 无角色权限 / 越权(含企业归属不匹配) |
| 400 | 1001, 3002-3006, 8404-8408, 8505, 9105 | 参数错误 / 非法请求体 / 资源未命中默认映射 |
| 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 8305, 8501 | 资源不存在 |
| 409 | 5003, 7003, 8001, 8003, 8301-8302, 8304, 8502-8504, 9101, 9103 | 重复 / 非法状态流转 |
| 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 8305, 8501, 10501, 10601, 10603 | 资源不存在 |
| 409 | 5003, 7003, 8001, 8003, 8301-8302, 8304, 8502-8504, 9101, 9103, 10502-10506, 10604-10605 | 重复 / 非法状态流转 / 版本或幂等冲突 |
| 500 | 1002, 9001 | 服务器内部错误 |
**前端策略**:优先判断 HTTP 状态码,再读取 `body.code` 显示业务信息。

2
docs/job-review-api.md

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

2
docs/match-api.md

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

2
docs/offer-advance-api.md

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

2
docs/offer-api.md

@ -1,6 +1,6 @@
# Offer 管理模块接口说明
> 职来 (mydreamjob) · Offer 管理基础模块
> 轻松聘 (mydreamjob) · Offer 管理基础模块
> 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>`
## 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 保留为当时批次的实测记录,不回溯改写。

13727
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>`
## 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;评价公开接口保持原路径
## 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'))
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']
@ -62,7 +62,9 @@ assert used_tags <= declared_tags, f'Undeclared operation tags: {sorted(used_tag
required = [
# Phase A 企业注册与入驻聚合(2 个 path / 2 个 operation)
'/auth/company/register',
'/auth/identity/select',
'/companies/me/onboarding',
'/companies/me/jobs',
'/review-weight-templates',
'/profile/me/review-weights',
'/profile/me/privacy',
@ -647,7 +649,7 @@ assert _auth_paths == {
'/auth/sms/send', '/auth/login', '/auth/me',
'/auth/refresh', '/auth/logout',
'/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset',
'/auth/company/register',
'/auth/company/register', '/auth/identity/select', '/auth/password/setup',
}, f'Unexpected /auth paths: {sorted(_auth_paths)}'
print(' S02 refresh/logout and password authentication operations validated')
@ -674,6 +676,18 @@ for _forbidden in ('materialsJson', 'decisionReason', 'reviewerId', 'storagePath
assert _forbidden not in _onboarding_text
print(' Phase A company registration/onboarding paths and redacted schemas validated')
# 企业岗位管理:私有全状态查询与公开岗位列表保持不同的安全边界。
assert set(paths['/companies/me/jobs']) == {'get'}
_company_jobs = paths['/companies/me/jobs']['get']
assert _company_jobs['operationId'] == 'getMyJobs'
assert {p['name'] for p in _company_jobs['parameters']} == {'status', 'keyword', 'city', 'page', 'size'}
assert _company_jobs['responses']['200']['content']['*/*']['schema']['$ref'] \
== '#/components/schemas/ApiResponsePageResponseCompanyManagedJobItem'
assert 'bearerAuth' in json.dumps(_company_jobs.get('security', []))
_company_job_properties = resolve_schema(d['components']['schemas']['CompanyManagedJobItem'])['properties']
assert {'id', 'companyId', 'title', 'city', 'salaryMin', 'salaryMax', 'status'} <= set(_company_job_properties)
print(' 企业自身全状态岗位列表路径、认证要求和强类型响应已验证')
# ── S03:唯一新增的企业简历下载接口 ────────────────────────────────
_download_path = '/company/candidates/{applicationId}/resume'
assert _download_path in paths, 'Missing company resume download endpoint'
@ -702,17 +716,209 @@ for _p in paths:
assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}'
print(' S03 company resume download contract validated; candidate DTOs remain redacted')
# 统计 operation 总数并校验 V43 + N07 + S02 + S03 + Phase A 计数 220
# 统计 operation 总数并校验当前接口契约基线。
operations = sum(len(methods) for methods in d['paths'].values())
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(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)")
schemas = {name.removeprefix('Lxq'): schema for name, schema in
d['components']['schemas'].items() if name.startswith('Lxq')}
onboarding_success_refs = {
('/onboarding/companies/{companyId}/checklist', 'get'): 'ApiResponseEffectiveChecklistResponse',
('/onboarding/companies/{companyId}/checklist/items/{itemId}/completion', 'put'): 'ApiResponseProgressUpdateResponse',
('/onboarding/companies/{companyId}/knowledge', 'get'): 'ApiResponseCompanyKnowledgeOverviewResponse',
('/onboarding/company/checklist-items', 'get'): 'ApiResponseListCompanyChecklistItemView',
('/onboarding/company/checklist-items', 'post'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/checklist-items/{itemId}', 'put'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/checklist-items/{itemId}', 'delete'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/knowledge', 'get'): 'ApiResponseCompanyKnowledgeView',
('/onboarding/company/knowledge', 'put'): 'ApiResponseCompanyKnowledgeView',
}
for (path, method), schema_name in onboarding_success_refs.items():
response = paths[path][method]['responses']['200']
ref = response['content']['*/*']['schema']['$ref']
assert ref == f'#/components/schemas/Lxq{schema_name}', f'Wrong success schema for {method.upper()} {path}: {ref}'
for status_code in ('401', '403'):
assert status_code in paths[path][method]['responses'], f'Missing {status_code} for {method.upper()} {path}'
unauthorized = paths[path][method]['responses']['401']
assert unauthorized['content']['application/json']['schema']['$ref'] == '#/components/schemas/LxqUnauthorizedResponse'
forbidden = paths[path][method]['responses']['403']
assert forbidden['content']['application/json']['schema']['$ref'] == '#/components/schemas/LxqApiResponse'
assert forbidden['content']['application/json']['example']['code'] == 2002
onboarding_schema_fields = {
'CompanyChecklistItemCreateRequest': {'title', 'description', 'category', 'sortOrder', 'required', 'idempotencyKey'},
'CompanyChecklistItemUpdateRequest': {'title', 'description', 'category', 'sortOrder', 'required', 'active', 'version'},
'CompanyChecklistItemView': {'itemId', 'source', 'itemKey', 'title', 'description', 'category', 'required', 'sortOrder', 'active', 'version'},
'EffectiveChecklistResponse': {'companyId', 'companyName', 'customized', 'totalItems', 'completedItems', 'completionRate', 'items'},
'ChecklistItemView': {'itemId', 'source', 'itemKey', 'title', 'description', 'category', 'required', 'sortOrder', 'active', 'completed', 'completedAt', 'progressVersion'},
'ProgressUpdateRequest': {'completed', 'version'},
'ProgressUpdateResponse': {'itemId', 'completed', 'completedAt', 'progressVersion', 'totalItems', 'completedItems', 'completionRate'},
'CompanyKnowledgeUpdateRequest': {'culture', 'workPolicies', 'onboardingGuide', 'version'},
'CompanyKnowledgeView': {'configured', 'culture', 'workPolicies', 'onboardingGuide', 'updatedAt', 'version'},
'CompanyKnowledgeOverviewResponse': {'company', 'knowledgeState', 'knowledge', 'reviewSummary'},
'CompanyView': {'id', 'name', 'logoUrl', 'description', 'industry', 'website', 'address', 'verified'},
'KnowledgeView': {'culture', 'workPolicies', 'onboardingGuide', 'updatedAt', 'version'},
'ReviewSummaryView': {'state', 'totalReviews', 'averageRating', 'ratingDistribution', 'topTags', 'dimensionSummary'},
'DimensionSummaryView': {'averageScore', 'sampleCount', 'dataState'},
}
for schema_name, expected_fields in onboarding_schema_fields.items():
schema = schemas.get(schema_name)
assert schema is not None, f'Missing onboarding schema: {schema_name}'
assert expected_fields <= set(schema.get('properties', {})), (
f'Onboarding schema fields missing: {schema_name}'
)
create_request = schemas['CompanyChecklistItemCreateRequest']['properties']
assert create_request['sortOrder'].get('default') == 1000, (
'CompanyChecklistItemCreateRequest.sortOrder default must be 1000'
)
assert create_request['required'].get('default') is False, (
'CompanyChecklistItemCreateRequest.required default must be false'
)
knowledge_success_example = paths['/onboarding/company/knowledge']['put']['responses']['200']['content']['*/*']['example']
assert knowledge_success_example['data'].get('version') == 2, (
'PUT /onboarding/company/knowledge 200 example data.version must be 2'
)
onboarding_error_paths = {
10501: ('/onboarding/company/checklist-items/{itemId}', 'put', '404'),
10502: ('/onboarding/company/checklist-items/{itemId}', 'put', '409'),
10503: ('/onboarding/company/checklist-items', 'post', '409'),
10504: ('/onboarding/company/checklist-items', 'post', '409'),
10505: ('/onboarding/companies/{companyId}/checklist/items/{itemId}/completion', 'put', '409'),
10506: ('/onboarding/company/knowledge', 'put', '409'),
}
for error_code, (path, method, status_code) in onboarding_error_paths.items():
response_text = json.dumps(paths[path][method]['responses'][status_code], ensure_ascii=False)
assert str(error_code) in response_text, f'Missing onboarding error code {error_code} in {method.upper()} {path}'
print(' N04 onboarding endpoints and schemas present (9 operations / 6 paths)')
# ── 岗位与企业展示字段批次:新增字段、约束与公开摘要白名单 ─────────────
# 本批未新增端点(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)
assert '"" : "#/components/schemas/' not in data, "Found empty ref key"
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("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>
<version>0.0.1-SNAPSHOT</version>
<name>mydreamjob-server</name>
<description>职来 (mydreamjob) Backend - Week 1 Baseline</description>
<description>轻松聘 (mydreamjob) Backend - Week 1 Baseline</description>
<properties>
<java.version>17</java.version>
@ -25,6 +25,16 @@
</properties>
<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 -->
<dependency>
<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
@Entity
@Table(name = "applications",
uniqueConstraints = @UniqueConstraint(name = "uk_applications_job_student",
columnNames = {"job_id", "student_user_id"}))
uniqueConstraints = {
@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)
public class Application {
@ -35,6 +38,17 @@ public class Application {
@Column(name = "student_user_id", nullable = false)
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")
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")
public ResponseEntity<ApiResponse<Map<String, Object>>> apply(
@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")
public ResponseEntity<ApiResponse<Map<String, Object>>> withdraw(
@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")
public ResponseEntity<ApiResponse<Map<String, Object>>> listMyApplications(
@RequestParam(required = false) String status,
@ -97,9 +97,9 @@ public class ApplicationController {
HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId();
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<>();
data.put("items", result.getContent().stream().map(applicationService::toView).collect(Collectors.toList()));
data.put("items", result.getContent());
data.put("total", result.getTotalElements());
data.put("page", result.getNumber());
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
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);

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.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.notification.NotificationService;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import com.mydreamjob.profile.StudentProfile;
@ -44,6 +47,7 @@ public class ApplicationService {
private final ApplicationRepository applicationRepository;
private final JobService jobService;
private final UserRepository userRepository;
private final CompanyRepository companyRepository;
private final AuditService auditService;
private final ApplicationStatusHistoryRepository historyRepository;
private final NotificationService notificationService;
@ -70,12 +74,18 @@ public class ApplicationService {
public Application apply(Long studentUserId, ApplicationCreateRequest req,
com.mydreamjob.audit.AuditContext ctx) {
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 (applicationRepository.existsByJobIdAndStudentUserId(req.getJobId(), studentUserId))
if (applicationRepository.findFirstByJobIdAndStudentUserIdAndStatusNotInOrderByAttemptNumberDesc(
req.getJobId(), studentUserId, Set.of("WITHDRAWN")).isPresent())
throw new BusinessException(ErrorCode.APPLICATION_DUPLICATE);
Application previous = applicationRepository
.findFirstByJobIdAndStudentUserIdOrderByAttemptNumberDesc(req.getJobId(), studentUserId)
.orElse(null);
Application app = Application.builder()
.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();
app = applicationRepository.save(app);
recordHistory(app.getId(), studentUserId, null, "SUBMITTED", "Student applied");
@ -101,7 +111,7 @@ public class ApplicationService {
public Application withdraw(Long studentUserId, Long applicationId,
com.mydreamjob.audit.AuditContext ctx) {
assertRole(studentUserId, "STUDENT");
Application app = applicationRepository.findById(applicationId)
Application app = applicationRepository.findLockedById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
if (!app.getStudentUserId().equals(studentUserId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
@ -132,12 +142,18 @@ public class ApplicationService {
/** 状态时间线 */
public List<Map<String, Object>> getTimeline(Long applicationId, Long userId) {
User actor = userRepository.findByIdWithRoles(userId).orElse(null);
Company actorCompany = null;
if (actor != null && actor.getRoles() != null && actor.getRoles().contains(User.Role.COMPANY)) {
actorCompany = VerifiedCompanyGuard.requireVerified(
companyRepository.findByOwnerUserId(userId).orElse(null));
}
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
// 仅学生本人或岗位所属企业可查看
Job job = jobService.getJob(app.getJobId());
boolean isStudent = app.getStudentUserId().equals(userId);
boolean isCompany = job.getCompanyId().equals(userId);
boolean isCompany = actorCompany != null && job.getCompanyId().equals(actorCompany.getId());
if (!isStudent && !isCompany) throw new BusinessException(ErrorCode.FORBIDDEN);
return historyRepository.findByApplicationIdOrderByCreatedAtAsc(applicationId)
@ -161,6 +177,35 @@ public class ApplicationService {
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) {
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
@ -172,6 +217,7 @@ public class ApplicationService {
// ── 企业端 ────────────────────────────────────────────────
public List<Application> listCompanyApplications(Long companyId, Long jobId) {
jobService.requireVerifiedCompany(companyId);
Job job = jobService.getJob(jobId);
if (!job.getCompanyId().equals(companyId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
@ -180,6 +226,7 @@ public class ApplicationService {
public Page<Application> listCompanyApplications(Long companyId, Long jobId, String status, int page, int size) {
if (page < 0 || size < 1 || size > 100) throw new BusinessException(ErrorCode.BAD_REQUEST);
jobService.requireVerifiedCompany(companyId);
Job job = jobService.getJob(jobId);
if (!job.getCompanyId().equals(companyId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
@ -197,7 +244,8 @@ public class ApplicationService {
@Transactional
public Application updateStatus(Long companyId, Long applicationId, ApplicationStatusRequest req,
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));
Job job = jobService.getJob(app.getJobId());
if (!job.getCompanyId().equals(companyId))
@ -233,6 +281,8 @@ public class ApplicationService {
view.put("jobId", app.getJobId());
view.put("studentUserId", app.getStudentUserId());
view.put("status", app.getStatus());
view.put("attemptNumber", app.getAttemptNumber());
view.put("previousApplicationId", app.getPreviousApplicationId());
view.put("createdAt", app.getCreatedAt());
view.put("updatedAt", app.getUpdatedAt());
return view;
@ -265,6 +315,7 @@ public class ApplicationService {
public CandidateListResponse listCompanyCandidates(Long companyId, Long jobId, String status,
String keyword, String sort, String order,
int page, int size) {
jobService.requireVerifiedCompany(companyId);
// 参数校验
if (page < 0 || size < 1 || size > 100)
throw new BusinessException(ErrorCode.BAD_REQUEST);
@ -293,7 +344,7 @@ public class ApplicationService {
String selectClause = "SELECT a.id, a.jobId, j.title, a.status, " +
"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 " +
"JOIN Job j ON a.jobId = j.id " +
"LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " +
@ -333,6 +384,8 @@ public class ApplicationService {
.candidateAvatarUrl((String) row[8])
.createdAt((LocalDateTime) row[9])
.updatedAt((LocalDateTime) row[10])
.attemptNumber((Integer) row[11])
.previousApplicationId((Long) row[12])
.build()).collect(Collectors.toList());
return CandidateListResponse.builder()
@ -349,6 +402,7 @@ public class ApplicationService {
*/
@Transactional(readOnly = true)
public CandidateDetailView getCompanyCandidateDetail(Long companyId, Long applicationId) {
jobService.requireVerifiedCompany(companyId);
// 单次 JOIN 查询:applications + jobs + student_profiles + users
// 查询结果包含岗位 companyId,数据隔离在 Service 层比较,避免额外的 findById 二次查询
TypedQuery<Object[]> query = em.createQuery(
@ -389,6 +443,8 @@ public class ApplicationService {
return CandidateDetailView.builder()
.applicationId(app.getId())
.attemptNumber(app.getAttemptNumber())
.previousApplicationId(app.getPreviousApplicationId())
.jobId(app.getJobId())
.jobTitle(job.getTitle())
.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) {
if (target == null) return false;
return switch (this) {
case SUBMITTED -> target == VIEWED || target == WITHDRAWN;
case VIEWED -> target == INTERVIEW || target == WITHDRAWN;
case SUBMITTED -> target == VIEWED || target == REJECTED || target == WITHDRAWN;
case VIEWED -> target == INTERVIEW || target == REJECTED || target == WITHDRAWN;
case INTERVIEW -> target == REJECTED || target == HIRED;
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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
import com.mydreamjob.privacy.ResumeVisibility;
@ -69,10 +70,11 @@ public class CompanyResumeAccessPolicy {
if (company == null) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
VerifiedCompanyGuard.requireVerified(company);
Application application = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
if (!isVisibleToCompanyInternal(application, company.getId())) {
if (!isVisibleToCompanyInternal(application, company)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return new Decision(application.getStudentUserId());
@ -88,17 +90,22 @@ public class CompanyResumeAccessPolicy {
if (studentUserId == null || companyId == null || applicationId == null) {
return false;
}
Company company = companyRepository.findById(companyId).orElse(null);
if (!VerifiedCompanyGuard.isVerified(company)) {
return false;
}
Application application = applicationRepository.findById(applicationId).orElse(null);
if (application == null || !studentUserId.equals(application.getStudentUserId())) {
return false;
}
return isVisibleToCompanyInternal(application, companyId);
return isVisibleToCompanyInternal(application, company);
}
/**
* 核心判定:投递所属岗位必须属于该企业,再按学生隐私设置决定可见性。
*/
private boolean isVisibleToCompanyInternal(Application application, Long companyId) {
private boolean isVisibleToCompanyInternal(Application application, Company company) {
Long companyId = company.getId();
Job job = jobRepository.findById(application.getJobId()).orElse(null);
if (job == null || !companyId.equals(job.getCompanyId())) {
// 岗位不存在或不属于当前企业:一律拒绝(含跨企业投递)。
@ -113,8 +120,7 @@ public class CompanyResumeAccessPolicy {
return application.getStatus() != null
&& APPLIED_COMPANY_VISIBLE_STATUSES.contains(application.getStatus());
case VERIFIED_COMPANIES:
Company company = companyRepository.findById(companyId).orElse(null);
return company != null && Boolean.TRUE.equals(company.getVerified());
return VerifiedCompanyGuard.isVerified(company);
case PRIVATE:
default:
// 无设置记录、null 或 PRIVATE:按 PRIVATE 处理。

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.storage.ResumeFilenameSanitizer;
import com.mydreamjob.profile.storage.ResumeStorageService;
import com.mydreamjob.profile.storage.ResumeOwnershipVerifier;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@ -39,6 +40,7 @@ public class CompanyResumeService {
private final CompanyResumeAccessPolicy accessPolicy;
private final StudentProfileRepository profileRepository;
private final ResumeStorageService resumeStorageService;
private final ResumeOwnershipVerifier resumeOwnership;
/**
* 读取该投递对应候选人的简历。
@ -60,6 +62,8 @@ public class CompanyResumeService {
throw new BusinessException(ErrorCode.PROFILE_NOT_FOUND);
}
// 企业获得的是该候选人的读取授权,不能将历史污染引用当作文件归属证明。
resumeOwnership.assertOwnedBy(storagePath, decision.studentUserId());
byte[] content = resumeStorageService.read(storagePath);
String filename = ResumeFilenameSanitizer.forDownload(profile.getResumeOriginalName(), "resume");
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 jobEmploymentType;
private String status;
private Integer attemptNumber;
private Long previousApplicationId;
private String coverLetter;
private LocalDateTime createdAt;
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 String jobTitle;
private String status;
private Integer attemptNumber;
private Long previousApplicationId;
private String candidateName;
private String candidateSchool;
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 AuthSessionService authSessionService;
private final Clock clock;
private final com.mydreamjob.auth.password.PasswordSetupService passwordSetupService;
/**
* Mock 登录:验证短信验证码后自动创建/登录用户,并建立一次登录会话。
@ -62,7 +63,8 @@ public class AuthService {
user = User.builder()
.phone(phone)
.nickname("用户_" + phone.substring(7))
.role(User.Role.STUDENT)
.role(User.Role.UNASSIGNED)
.roles(Set.of())
.active(true)
.verified(true)
.build();
@ -97,6 +99,15 @@ public class AuthService {
result.put("phone", user.getPhone());
result.put("nickname", user.getNickname());
result.put("role", user.getRole());
result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED);
result.put("passwordConfigured", user.getPasswordHash() != null);
if ("login".equals(purpose) && user.getPasswordHash() == null) {
String setupProof = passwordSetupService.issue(user, tokens.sessionId(), capturedNow);
if (setupProof != null) {
result.put("passwordSetupToken", setupProof);
result.put("passwordSetupExpiresIn", 600);
}
}
result.put("accessToken", tokens.accessToken());
result.put("refreshToken", tokens.refreshToken());
result.put("expiresIn", EXPIRES_IN_SECONDS);
@ -137,7 +148,9 @@ public class AuthService {
result.put("nickname", user.getNickname());
result.put("avatarUrl", user.getAvatarUrl());
result.put("role", user.getRole());
result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED);
result.put("active", user.getActive());
result.put("passwordConfigured", user.getPasswordHash() != null);
result.put("verified", user.getVerified());
result.put("createdAt", user.getCreatedAt());
return result;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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);
/** 撤回后的历史直聊只保留阅读,重新发送必须重新取得有效匹配。 */
@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 的投递(用于建会话守卫) */
@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,
@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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import lombok.RequiredArgsConstructor;
@ -78,10 +79,11 @@ public class BidirectionalMatchService {
Long userId, Long applicationId, int score, AuditContext ctx) {
User user = loadUser(userId);
assertRole(user, User.Role.COMPANY);
Company company = VerifiedCompanyGuard.requireVerified(
companyRepository.findByOwnerUserId(userId).orElse(null));
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Long companyId = companyRepository.findByOwnerUserId(userId)
.map(Company::getId).orElse(null);
Long companyId = company.getId();
Job job = jobService.getJob(app.getJobId());
if (companyId == null || !companyId.equals(job.getCompanyId())) {
throw new BusinessException(ErrorCode.FORBIDDEN);
@ -97,6 +99,10 @@ public class BidirectionalMatchService {
// ── 匹配状态查询(双方可读) ──────────────────────────────
public Map<String, Object> getMatchView(Long userId, Long applicationId) {
User actor = userRepository.findByIdWithRoles(userId).orElse(null);
if (actor != null && actor.getRoles() != null && actor.getRoles().contains(User.Role.COMPANY)) {
VerifiedCompanyGuard.requireVerified(companyRepository.findByOwnerUserId(userId).orElse(null));
}
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId());
@ -139,9 +145,11 @@ public class BidirectionalMatchService {
private void assertParticipant(Long userId, Application app, Job job) {
if (app.getStudentUserId().equals(userId)) return;
Long companyId = companyRepository.findByOwnerUserId(userId)
.map(Company::getId).orElse(null);
if (companyId != null && companyId.equals(job.getCompanyId())) return;
Company company = companyRepository.findByOwnerUserId(userId).orElse(null);
if (company != null && company.getId().equals(job.getCompanyId())) {
VerifiedCompanyGuard.requireVerified(company);
return;
}
throw new BusinessException(ErrorCode.FORBIDDEN);
}

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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.messaging.Conversation;
@ -43,6 +44,8 @@ public class MatchConversationService {
@Transactional
public Map<String, Object> createOrGetMatchConversation(Long userId, Long applicationId) {
Company ownedCompany = companyRepository.findByOwnerUserId(userId).orElse(null);
if (ownedCompany != null) VerifiedCompanyGuard.requireVerified(ownedCompany);
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId());
@ -56,8 +59,7 @@ public class MatchConversationService {
.map(Company::getOwnerUserId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
} else {
Long actorCompanyId = companyRepository.findByOwnerUserId(userId)
.map(Company::getId).orElse(null);
Long actorCompanyId = ownedCompany == null ? null : ownedCompany.getId();
if (actorCompanyId == null || !actorCompanyId.equals(companyId)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
@ -65,6 +67,9 @@ public class MatchConversationService {
}
// 必须已 MATCHED
if ("WITHDRAWN".equals(app.getStatus()) || "REJECTED".equals(app.getStatus())) {
throw new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED);
}
ApplicationMatchRecord record = recordRepository.findByApplicationId(applicationId).orElse(null);
if (record == null || !ApplicationMatchRecord.STATUS_MATCHED.equals(record.getStatus())) {
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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.security.UserRoleGuard;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@ -33,6 +34,7 @@ public class MatchRuleService {
@Transactional(readOnly = true)
public Map<String, Object> getCompanyRule(Long userId) {
Company company = requireCompany(userId);
VerifiedCompanyGuard.requireVerified(company);
CompanyMatchRule rule = companyRuleRepository.findByCompanyId(company.getId()).orElse(null);
int required = rule != null ? rule.getRequiredCandidateScore()
: CompanyMatchRule.DEFAULT_REQUIRED_CANDIDATE_SCORE;
@ -42,6 +44,7 @@ public class MatchRuleService {
@Transactional
public Map<String, Object> updateCompanyRule(Long userId, int requiredCandidateScore) {
Company company = requireCompany(userId);
VerifiedCompanyGuard.requireVerified(company);
CompanyMatchRule rule = companyRuleRepository.findByCompanyId(company.getId())
.orElseGet(() -> CompanyMatchRule.builder().companyId(company.getId()).build());
rule.setRequiredCandidateScore(requiredCandidateScore);

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

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

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

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

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_OWNER_MISMATCH(5002, "company owner mismatch"),
COMPANY_ALREADY_EXISTS(5003, "company already exists"),
COMPANY_NOT_VERIFIED(5004, "company not verified"),
// 学生资料 6xxx
PROFILE_NOT_FOUND(6001, "profile not found"),
ONLINE_RESUME_REVISION_CONFLICT(6002, "online resume changed; refresh before saving"),
ONLINE_RESUME_ENTRY_NOT_FOUND(6003, "online resume entry not found"),
// 岗位 7xxx
JOB_NOT_FOUND(7001, "job not found"),
@ -88,6 +91,7 @@ public enum ErrorCode {
MESSAGE_NOT_FOUND(8306, "message not found"),
MESSAGE_RECALL_WINDOW_EXPIRED(8307, "message recall window expired"),
MESSAGE_RECALL_CONCURRENT_UPDATE(8308, "message recall concurrent update"),
RESUME_MESSAGE_UNAVAILABLE(8309, "resume message is no longer available"),
// 评价 91xx
DUPLICATE_REVIEW(9101, "duplicate review"),
@ -239,7 +243,22 @@ public enum ErrorCode {
COMPANY_REGISTRATION_DISABLED(10301, "company registration is disabled"),
COMPANY_REGISTRATION_CONFLICT(10302, "company registration conflict"),
COMPANY_ONBOARDING_INCONSISTENT_STATE(10303, "company onboarding state is inconsistent"),
COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE(10304, "company registration dependency unavailable");
COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE(10304, "company registration dependency unavailable"),
IDENTITY_SELECTION_REQUIRED(10401, "identity selection is required"),
IDENTITY_SELECTION_CONFLICT(10402, "identity selection conflict"),
IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE(10403, "identity selection temporarily unavailable"),
ONBOARDING_CHECKLIST_ITEM_NOT_FOUND(10501, "onboarding checklist item not found"),
ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE(10502, "onboarding checklist item was modified concurrently"),
ONBOARDING_ITEM_LIMIT_EXCEEDED(10503, "onboarding checklist item limit exceeded"),
ONBOARDING_IDEMPOTENCY_CONFLICT(10504, "idempotency key conflict: request body differs"),
ONBOARDING_PROGRESS_CONCURRENT_UPDATE(10505, "onboarding progress was modified concurrently"),
ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE(10506, "company knowledge was modified concurrently"),
SALARY_CITY_NOT_FOUND(10601, "salary city not found or disabled"),
SALARY_INVALID_PARAMETER(10602, "invalid salary calculation parameter"),
SALARY_HISTORY_NOT_FOUND(10603, "salary calculation history not found"),
SALARY_IDEMPOTENCY_CONFLICT(10604, "salary idempotency key conflict: request body differs"),
SALARY_POLICY_VERSION_CONFLICT(10605, "salary city policy version conflict");
private final int code;
private final String message;

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

@ -111,11 +111,16 @@ public final class ErrorHttpStatusRegistry {
return new Builder()
.put(HttpStatus.FORBIDDEN,
ErrorCode.APPLICATION_OWNER_MISMATCH,
ErrorCode.COMPANY_NOT_VERIFIED,
ErrorCode.IDENTITY_SELECTION_REQUIRED,
ErrorCode.FORBIDDEN,
ErrorCode.INTERVIEW_ACCESS_DENIED,
ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION,
ErrorCode.OFFER_COMPARE_NOT_OWNER)
.put(HttpStatus.NOT_FOUND,
ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND,
ErrorCode.SALARY_CITY_NOT_FOUND,
ErrorCode.SALARY_HISTORY_NOT_FOUND,
ErrorCode.ABILITY_ATTEMPT_NOT_FOUND,
ErrorCode.ABILITY_RESULT_NOT_FOUND,
ErrorCode.ABILITY_TYPE_NOT_FOUND,
@ -133,6 +138,7 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND,
ErrorCode.OFFER_NOT_FOUND,
ErrorCode.PROFILE_NOT_FOUND,
ErrorCode.ONLINE_RESUME_ENTRY_NOT_FOUND,
ErrorCode.REPORT_NOT_FOUND,
ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND,
ErrorCode.RESUME_VERSION_NOT_FOUND,
@ -144,7 +150,16 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.USER_NOT_FOUND,
ErrorCode.VERIFICATION_NOT_FOUND)
.put(HttpStatus.CONFLICT,
ErrorCode.ONLINE_RESUME_REVISION_CONFLICT,
ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE,
ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED,
ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT,
ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE,
ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE,
ErrorCode.SALARY_IDEMPOTENCY_CONFLICT,
ErrorCode.SALARY_POLICY_VERSION_CONFLICT,
ErrorCode.PASSWORD_STATE_CONFLICT,
ErrorCode.IDENTITY_SELECTION_CONFLICT,
ErrorCode.ABILITY_ATTEMPT_INVALID_STATE,
ErrorCode.ABILITY_CONCURRENT_UPDATE,
ErrorCode.ABILITY_DUPLICATE_ACTIVE,
@ -192,6 +207,7 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.MATCH_SCORES_LOCKED,
ErrorCode.MESSAGE_RECALL_CONCURRENT_UPDATE,
ErrorCode.MESSAGE_RECALL_WINDOW_EXPIRED,
ErrorCode.RESUME_MESSAGE_UNAVAILABLE,
ErrorCode.OFFER_COUNTER_OFFER_EXISTS,
ErrorCode.OFFER_COUNTER_OFFER_EXPIRED,
ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING,
@ -226,6 +242,7 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE)
.put(HttpStatus.SERVICE_UNAVAILABLE,
ErrorCode.COMPANY_REGISTRATION_DISABLED,
ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE,
ErrorCode.COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE)
.put(HttpStatus.TOO_MANY_REQUESTS,
ErrorCode.PASSWORD_RATE_LIMITED,

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

@ -1,6 +1,7 @@
package com.mydreamjob.common.exception;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.salarycalculator.SalaryPolicyVersionConflictException;
import com.mydreamjob.common.logging.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
@ -39,6 +40,20 @@ public class GlobalExceptionHandler {
private final RequestIdAccessor requestIdAccessor;
private final ErrorHttpStatusRegistry errorHttpStatusRegistry;
public record SalaryPolicyConflictDetails(String latestPolicyVersion,
java.time.LocalDate policyEffectiveDate) {}
@ExceptionHandler(SalaryPolicyVersionConflictException.class)
public ResponseEntity<ApiResponse<SalaryPolicyConflictDetails>> handleSalaryPolicyConflict(
SalaryPolicyVersionConflictException ex, HttpServletRequest request) {
ErrorCode code = ErrorCode.SALARY_POLICY_VERSION_CONFLICT;
ApiResponse<SalaryPolicyConflictDetails> body = ApiResponse.error(
code.getCode(), code.getMessage(), getRequestId(request));
body.setData(new SalaryPolicyConflictDetails(
ex.getLatestPolicyVersion(), ex.getLatestPolicyEffectiveDate()));
return ResponseEntity.status(errorHttpStatusRegistry.resolve(code.getCode())).body(body);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleConstraintViolation(
ConstraintViolationException ex, HttpServletRequest request) {

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

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

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.CompanyUpdateRequest;
import com.mydreamjob.job.JobService;
import com.mydreamjob.job.dto.CompanyManagedJobItem;
import com.mydreamjob.job.dto.JobSearchItem;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
@ -74,6 +75,7 @@ public class CompanyService {
.logoUrl(req.getLogoUrl())
.description(req.getDescription())
.industry(req.getIndustry())
.companySize(normalizeCompanySize(req.getCompanySize()))
.website(req.getWebsite())
.address(req.getAddress())
.verified(false)
@ -94,12 +96,43 @@ public class CompanyService {
if (req.getLogoUrl() != null) company.setLogoUrl(req.getLogoUrl());
if (req.getDescription() != null) company.setDescription(req.getDescription());
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.getAddress() != null) company.setAddress(req.getAddress());
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 视图(排除敏感字段)
*/
@ -112,6 +145,7 @@ public class CompanyService {
view.put("logoUrl", company.getLogoUrl());
view.put("description", company.getDescription());
view.put("industry", company.getIndustry());
view.put("companySize", company.getCompanySize());
view.put("website", company.getWebsite());
view.put("address", company.getAddress());
view.put("verified", company.getVerified());
@ -142,6 +176,7 @@ public class CompanyService {
.logoUrl(company.getLogoUrl())
.description(company.getDescription())
.industry(company.getIndustry())
.companySize(company.getCompanySize())
.verified(company.getVerified())
.build();
}
@ -167,4 +202,13 @@ public class CompanyService {
size // size
);
}
/** 当前企业自身岗位管理列表;身份和企业归属均由服务端解析。 */
public Page<CompanyManagedJobItem> getMyJobs(Long userId, String status,
String keyword, String city,
int page, int size) {
Company company = findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
return jobService.listCompanyJobs(company.getId(), status, keyword, city, page, size);
}
}

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

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

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

@ -1,5 +1,6 @@
package com.mydreamjob.company.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@ -13,6 +14,13 @@ public class CompanyCreateRequest {
private String logoUrl;
private String description;
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 address;
}

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

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

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

@ -1,5 +1,6 @@
package com.mydreamjob.company.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@ -22,6 +23,12 @@ public class CompanyUpdateRequest {
@Size(max = 80, message = "industry must be at most 80 characters")
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")
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() {
return new OpenAPI()
.info(new Info()
.title("职来 API")
.title(com.mydreamjob.common.ProductBrand.API_TITLE)
.version("v1"));
}
}

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

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

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

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

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

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

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

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

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

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

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

Loading…
Cancel
Save