Compare commits

...

28 Commits

Author SHA1 Message Date
Zhang Shun cc3f72a56f feat(notification): 完善业务关联、已读和通知回收站 6 hours ago
Zhang Shun 9fdb72a8e8 feat(profile): 打通个人头像与企业Logo上传读取 1 day ago
Zhang Shun c6ccab5404 feat(resume): add consent-aware AI work experience optimization with production safeguards 1 day ago
Zhang Shun af80abddf7 feat(chat): add authenticated image and file attachments 5 days ago
Zhang Shun 7f5c22575f feat(application): unlock communication after company acceptance 5 days ago
Zhang Shun e46efe55ac feat(job): add map locations and bounded place search; serialize session logout 6 days ago
Zhang Shun d5889ba98c fix(resume): protect legacy writes with revision and classify internships 1 week ago
Zhang Shun 2770d4c544 test(career-progress): 补充分页和可选摘要契约回归 1 week 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 1 month 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. 47
      docs/AI-001-工作经历AI优化-前端对接方案.md
  6. 72
      docs/AI-001-工作经历AI优化-后端整合说明.md
  7. 55
      docs/AI-001-工作经历AI优化-质量评审.md
  8. 2
      docs/ERD.md
  9. 103
      docs/_update_job_company_display_fields_openapi.py
  10. 150
      docs/acceptance/application-reapplication-20261020/blackbox.py
  11. 25
      docs/acceptance/application-reapplication-20261020/verify_contract.py
  12. 354
      docs/acceptance/batch-display-fields-20261016/b01_blackbox.py
  13. 140
      docs/acceptance/batch-display-fields-20261016/b01_contract_negative_tests.py
  14. 249
      docs/acceptance/batch-display-fields-20261016/b01_openapi_compare.py
  15. 72
      docs/ai-work-experience-consent-frontend-20261026.md
  16. 54
      docs/ai-work-experience-production-config-20261026.md
  17. 67
      docs/application-reapplication-handover-20261020.md
  18. 5
      docs/backend-development-roadmap.md
  19. 266
      docs/batch-job-company-display-fields-frontend-handover.md
  20. 115
      docs/batch-job-company-display-fields-report.md
  21. 119
      docs/chat-attachments-frontend-handover-20261023.md
  22. 68
      docs/chat-realtime-api.md
  23. 73
      docs/first-password-setup-implementation.md
  24. 17
      docs/frontend-integration.md
  25. 39
      docs/job-map-runtime-configuration.md
  26. 2
      docs/job-review-api.md
  27. 2
      docs/match-api.md
  28. 66
      docs/notification-links-development-verification-20261028.md
  29. 180
      docs/notification-links-frontend-handover-20261028.md
  30. 64
      docs/notification-trash-development-verification-20261028.md
  31. 125
      docs/notification-trash-frontend-handover-20261028.md
  32. 2
      docs/offer-advance-api.md
  33. 2
      docs/offer-api.md
  34. 434
      docs/onboarding-api.md
  35. 104
      docs/online-resume-frontend-handover-20261020.md
  36. 36
      docs/online-resume-r01e-query-budget-20261020.md
  37. 18060
      docs/openapi-rc.json
  38. 163
      docs/phase-b-company-verification-gate-implementation-report.md
  39. 136
      docs/phase-b-company-verification-gate-independent-verification-20260923.md
  40. 90
      docs/profile-images-development-verification-20261027.md
  41. 100
      docs/profile-images-frontend-handover-20261027.md
  42. 19
      docs/resume-api.md
  43. 129
      docs/resume-ownership-hardening-20261016.md
  44. 2
      docs/review-moderation-api.md
  45. 198
      docs/salary-calculator-api.md
  46. 275
      docs/skill-cloud-api.md
  47. 79
      docs/skill-taxonomy-sources.md
  48. 866
      docs/tools/b01_demo_display_data.py
  49. 594
      docs/tools/b01_demo_display_data_test.py
  50. 49
      docs/unified-registration-identity-selection-design.md
  51. 159
      docs/unified-registration-identity-selection-implementation-report.md
  52. 388
      docs/verify_openapi.py
  53. 4
      mydreamjob-server/README.md
  54. 12
      mydreamjob-server/pom.xml
  55. 22
      mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java
  56. 41
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationAcceptanceController.java
  57. 58
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationAcceptanceService.java
  58. 16
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationCommunicationPolicy.java
  59. 10
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java
  60. 12
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java
  61. 69
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java
  62. 4
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatus.java
  63. 16
      mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java
  64. 4
      mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java
  65. 2
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java
  66. 2
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java
  67. 15
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java
  68. 6
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java
  69. 5
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java
  70. 23
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsFailedAttemptRecorder.java
  71. 6
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java
  72. 12
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java
  73. 1
      mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java
  74. 6
      mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java
  75. 4
      mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java
  76. 11
      mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupRequest.java
  77. 6
      mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupResponse.java
  78. 76
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java
  79. 31
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionRequest.java
  80. 8
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionResponse.java
  81. 104
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionService.java
  82. 5
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java
  83. 18
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallenge.java
  84. 16
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallengeRepository.java
  85. 73
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupService.java
  86. 57
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/AliyunSmsCodeSender.java
  87. 35
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/AliyunSmsProperties.java
  88. 3
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java
  89. 26
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java
  90. 2
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java
  91. 2
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java
  92. 15
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java
  93. 20
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java
  94. 4
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java
  95. 20
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java
  96. 3
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java
  97. 2
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java
  98. 4
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java
  99. 10
      mydreamjob-server/src/main/java/com/mydreamjob/common/ProductBrand.java
  100. 25
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java

6
README.md

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

2
backend-infra/README.md

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

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

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

77
changes/n03-skill-cloud/data/source-manifest.md

@ -0,0 +1,77 @@
# N03 行业技能词云来源与覆盖清单
## 1. 固定来源
| 来源 | 固定版本 | 用途 | 许可/复用条件 | 官方地址与归属 |
| --- | --- | --- | --- | --- |
| ESCO | ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0) | 提供行业/职位映射所需的职业—技能—技能分类主关系、稳定来源 ID 和层级结构 | 依据 Commission Decision 2011/833/EU,可免费下载、使用、复制和复用。文档和产品界面须保留官方署名语句:`This service uses the ESCO classification of the European Commission.` | 数据集下载页:https://esco.ec.europa.eu/en/use-esco/download<br>复用条件说明:https://esco.ec.europa.eu/en/about-esco/faq |
| O*NET | O*NET 31.0 Database | 为 ESCO 未收录的具体技术提供 `software_skills` 补充来源,并通过职业/工作活动 Importance 提供首版词云热度 `heat` | License 为 CC BY 4.0,归属与许可说明见 https://creativecommons.org/licenses/by/4.0/ | 官方数据库页:https://www.onetcenter.org/database.html |
运行时不得访问 ESCO、O*NET 或任何在线词库服务。所有首版数据必须随 Flyway 迁移进入本地数据库,并保留来源版本与归属信息。
## 2. 首版覆盖边界
首版只覆盖互联网、金融、教育三类行业及下列 10 个已列代表性职位。该矩阵定义导入上限和最小覆盖范围,不要求在全量上游数据中导入未列出的职位。
| 行业 | 代表性职位 | 首批技能分类范围 |
| --- | --- | --- |
| 互联网 | 前端开发工程师 | 编程语言、Web 基础、前端框架、工程化工具、浏览器与调试、测试、版本控制与协作 |
| 互联网 | 后端开发工程师 | 编程语言、后端框架与平台、数据库、缓存与消息、云/DevOps、测试、工程化工具、安全 |
| 互联网 | 测试工程师 | 编程语言、测试设计、自动化测试框架、接口与数据库测试、性能测试、CI/CD 与工程化工具 |
| 互联网 | DevOps 工程师 | 编程语言与脚本、云平台、容器与编排、CI/CD、配置管理与 IaC、可观测性、安全、数据库 |
| 金融 | 金融数据分析师 | 编程语言、数据分析与可视化、数据库与数据仓库、统计与建模、金融计算框架/平台、金融领域知识、工程化工具 |
| 金融 | 风控建模工程师 | 编程语言、统计与建模、风险模型、建模框架/平台、数据处理与数据库、模型验证、部署与工程化工具 |
| 金融 | 量化开发工程师 | 编程语言、量化建模框架/平台、市场与金融数据、数据库、低延迟与高性能工程、测试、云/DevOps |
| 教育 | 教育产品经理 | 教育领域知识、产品分析方法、需求与流程、数据分析、教育平台与工具、协作与工程化工具 |
| 教育 | 教学设计师 | 教学设计、课程与学习平台、数字化内容工具、评估设计、学习数据分析、无障碍与规范 |
| 教育 | 教育数据分析师 | 编程语言、数据分析与可视化、数据库与 SQL、统计与建模、教育领域知识、报表与工程化工具 |
计数说明:任务给出的逐项职位清单为互联网 4 个、金融 3 个、教育 3 个,共 10 个。验收文字中的“11 个职位”与该枚举不一致;本清单严格覆盖逐项列出的 10 个职位,不自行新增未批准职位。
矩阵中的分类是每个职位首批应覆盖的能力域;T3 只导入这些分类下已确定稳定来源 ID、标准技能和中文标准名的技能,不在此文档中展开具体 `INSERT` 行。
## 3. 导入规则
1. **职责分工**:T2 只建立七张词库表、外键、唯一约束和查询索引;T3 只导入上述三类行业、10 个已列代表性职位及其关联技能分类、技能、别名和 O*NET Importance。
2. **代表性子集与混合来源**:不导入 ESCO/O*NET 全量数据;ESCO 是职位、通用技能和分类主表;ESCO 无可靠节点的具体技术使用 O*NET `software_skills` 补充;不把平台自由文本岗位或行业直接当作标准词库节点。
3. **来源 ID 不变**:ESCO/O*NET 来源 ID 原样保存在稳定来源字段中;项目不得重编号、覆盖或伪造上游 ID。
4. **中文标准名必填**:每个入选技能必须有非空 `name_zh` 中文标准名;中文名称由项目维护,但不替代或修改上游来源 ID。
5. **热度来源**:每条非空 Importance 必须保存 O*NET SOC、Element ID 和代理标记;技能无法映射 O*NET Importance 时必须保留,写入 `importance=null`;查询时映射为 `heat=null`,并排在有热度技能之后。
6. **项目映射责任**:项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。行业/职位映射、细粒度分类和跨源技能对应关系均作为项目适配层记录。
7. **别名归一化**:生成 `normalized_alias` 时依次执行 Unicode NFKC、首尾空白移除、大小写折叠、连字符转空格、连续空白折叠为单空格、句点移除;`+` 和 `#` 必须保留,不能删除或替换。
8. **来源可追溯**:来源清单写入 `skill_source_versions`,至少记录来源名称、固定版本、许可、归属 URL 和导入时间;O*NET 补充技术的 `source_code` 使用真实 `SOC|Workplace Example|Element ID` 组合,职位技能关系记录 O*NET SOC、Element ID、代理标记及关联的来源版本。
9. **迁移不可改写**:T2/T3 计划使用 `V35__skill_cloud_taxonomy.sql` 和 `V36__skill_cloud_seed.sql`。执行前若编号已被占用,必须回退规划并刷新编号;后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或任何已执行迁移。
10. **运行时离线**:接口运行时不得调用外部词库、AI、课程平台或同步服务;排名只读取本地 Flyway 数据。
## 4. 来源版本、许可与归属 URL
| 数据落点 | 来源名称 | 版本字段固定值 | 许可字段固定值 | 归属 URL |
| --- | --- | --- | --- | --- |
| 职业、技能、技能分类及关系 | ESCO | `1.2.1 (Release v5.7.0, Last update 10/12/2025)` | `Commission Decision 2011/833/EU` | https://esco.ec.europa.eu/en/use-esco/download |
| 职位技能 Importance | O*NET | `31.0 Database` | `CC BY 4.0` | https://www.onetcenter.org/database.html |
ESCO 产品署名必须使用:`This service uses the ESCO classification of the European Commission.`
## 5. 映射责任边界
| 事项 | 上游来源责任 | 项目责任 |
| --- | --- | --- |
| ESCO 职业、技能、分类层级和来源 ID | 固定且可追溯 | 原样保存,不修改 ID |
| O*NET Importance 数值 | 固定且可追溯 | 原样保存;无映射时写 `null`,不造权重 |
| 中文名称和别名 | 不保证完整中文覆盖 | 补齐中文标准名、别名及归一化值 |
| 细粒度技能分类 | 不保证符合本项目展示粒度 | 在职位下维护分类和展示名称 |
| 行业/职位映射 | 不保证覆盖三类项目行业和中文职位名 | 维护行业—职位—分类—技能映射 |
| 跨源技能与重要性关联 | 不提供项目所需的稳定本地关系 | 维护 ESCO 技能到 O*NET `software_skills` 的补充映射,以及每条 Importance 的 SOC/Element ID/代理标记 |
## 6. 验收检查项
- AC-1:`changes/n03-skill-cloud/data/source-manifest.md` 与 `docs/skill-taxonomy-sources.md` 均存在且非空,来源版本、许可、归属 URL、覆盖矩阵和导入规则一致。
- AC-2:文档明确覆盖互联网、金融、教育三类行业及全部 10 个已列代表性职位,并为每个职位给出首批技能分类范围。
- AC-3:文档明确固定 ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0)和 O*NET 31.0 Database。
- AC-4:文档明确 ESCO 依据 Commission Decision 2011/833/EU 可免费下载、使用、复制和复用,并原样保留指定署名语句。
- AC-5:文档明确 O*NET License 为 CC BY 4.0,并记录官方数据库页与许可 URL。
- AC-6:文档明确项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。
- AC-7:文档明确 T2 建结构、T3 导数据,只导入代表性职位和关联技能;技能中文标准名必填。
- AC-8:文档明确 O*NET 补充技术、每条 Importance 的 SOC/Element ID/代理标记,以及无映射时保留技能并写入 `importance=null`、查询侧映射为 `heat=null`。
- AC-9:文档明确别名归一化遵循 NFKC、首尾空白、大小写、连字符、连续空白、句点规则,且保留 `+`、`#`。
- AC-10:文档明确后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或已执行迁移。
- AC-11:来源、覆盖、导入与责任边界中不得出现未决占位或延期确认语句。

47
docs/AI-001-工作经历AI优化-前端对接方案.md

@ -0,0 +1,47 @@
# AI-001 工作经历AI优化前端对接方案
> 2026-10-26 A7 新候选:真实模式改为 mode=deepseek + real-enabled=true 显式开启,不再要求 dev;新增 Redis 调用额度与并发门禁(429/Retry-After=60、Redis 故障503)。告知升级 work-experience-external-processing-v2,developmentOnly=false。下文旧版本/仅开发环境及旧验收记录属于历史,当前规则和上线边界以 [正式环境配置说明](ai-work-experience-production-config-20261026.md) 为准;本批尚未部署,不代表合规审核完成。
日期:2026-10-23。交付范围:后端AI建议生成;前端由前端负责人实施,复用原页面及保存流程。
POST /api/v1/profile/me/work-experience-optimization,Content-Type application/json,Authorization: Bearer <access-token>,仅当前STUDENT。不得传用户ID。
```json
{"position":"后端开发工程师","description":"负责订单查询接口开发,使用Java和MySQL,参与问题排查。","targetPosition":"Java后端开发","externalProcessingConsent":true,"consentNoticeVersion":"work-experience-external-processing-v2"}
```
position、targetPosition可选各最多100字符;description必填非空最多3000字符。2026-10-26 后端新候选要求 externalProcessingConsent 为原生布尔 true,且 consentNoticeVersion 与本次告知接口一致;旧请求缺版本将被拒绝。具体展示、拒绝及重新确认流程见 [当前告知与同意接入说明](ai-work-experience-consent-frontend-20261026.md)。下文未涉及该版本字段的来源记录仅作历史。
成功HTTP200使用ApiResponse,code=200、message=ok,data字段:status为OPTIMIZED/NEED_MORE_INFO;optimizedDescription为建议,信息不足可null或省略;suggestions、followUpQuestions为字符串数组;promptVersion为后端确定的提示词版本标识(不代表内容事实已校验);simulated标识是否模拟。真实模式为false/work-experience-deepseek-v2,stub为true/work-experience-v1,不限制dev profile。前端不要按固定提示词版本阻断展示。无流式或分页;429读取Retry-After但不自动重试,Redis故障返回503。
错误HTTP400参数或敏感输入拒绝(code可能400/1001);401未登录;403角色拒绝(可能code2002);404用户不存在;502无效模型结果;503关闭或模型不可用;504超时。模型错误仅安全固定文案,不回传上游正文。HTTP状态与业务code不能一概等同。
按钮流程:取当前一条描述→显示加载并防重复点击→展示原文/建议/补充问题→允许编辑、采用、取消→采用回填工作内容→沿用已有保存。失败/取消保留原文,原文已改变则丢弃旧响应。可设置25秒前端等待,超时不自动重试(供应商仍可能计费)。本接口不写数据库,不改变公司/时间、评分或企业可见性。
验证:源目录离线22项20通过2跳过;用户本机单例真实调用与5例合成质量评审有证据。目标仓库移植验证见后端整合说明。实际启动后的HTTP/JWT黑盒、前端、完整生产数据治理与至少30例评测仍未完成。当前只允许dev合成联调,告知文案按用户要求暂缓,不视为可上线。
## v2 规则与版本(后续修正候选)
历史验证段描述来源交付时的状态。随后 v1 在目标工程完成离线/JAR 链路验证和单次真实联调,但质量批次在第 29 例因“不负责”被改成“不参与”而停止,第 30 例未执行,不能作为质量放行。
v2 要求保留否定条件、参与程度、指导/责任/权限边界及时间/上线状态。补充建议必须以“仅在真实发生且可核实时补充”为前提;原文已清晰时允许不改并诚实说明。采用前仍应让用户核对,不默认覆盖原文;API 字段、路径、保存流程、stub 和默认关闭行为均不变。
## 离线验证边界
离线回归只校验 v2 资源与版本一致、规则被发送、原文无损传递、合成响应解析及权限等程序行为;不能证明真实模型遵守规则,也不代表输出已脱敏或事实忠实。
## 待授权真实复验
v2 尚未执行真实模型质量复验。需用新候选制品重新验证已失败及边界遗漏用例,再经单独授权完成至少 30 例质量评测;不得复用 v1 通过项宣称 v2 质量通过。当前不发布、不开放生产真实模式。
## 2026-10-26 信息不足修正候选
### 规则与版本
此前 A3 v2 已实际复验 16 次:15 例质量通过,Q09“暂无相关经历。”被返回为 OPTIMIZED 后停止,14 例未执行,不是完整通过。新候选对有限登记的无经历、占位、空泛评价和纯愿望语句增加本地不足信息判定;只有整段各分句均符合才直接要求补充,短真实职责及含具体事实的混合句不因长度或“暂无”字样被拒绝。模型若返回同类无事实语句,也归一为空描述。
接口字段和路径不变。`status=NEED_MORE_INFO` 时展示 `followUpQuestions`,不显示“优化成功”、不启用采用/覆盖原文;`optimizedDescription` 可 null 或省略。`simulated=false` 仅代表真实运行模式,不保证本次调用过供应商,本地规则也可能直接返回;不得用它推算计费或质量案例数。提示词仍为 v2,新 JAR/基线哈希区分本次规则修正。
### 离线验证边界与真实复验状态
新回归覆盖本地不足信息、短真实经历及混合事实放行、权限/同意/敏感输入保护和无自动保存。新基线的全量、同次 JAR 验证与真实模型质量需分别记录;原有 v1/A3 质量失败保留。有限规则不保证识别所有自然语言;新候选未据此开放生产。本批不改前端,以上供后续联调对接。

72
docs/AI-001-工作经历AI优化-后端整合说明.md

@ -0,0 +1,72 @@
# AI-001 工作经历AI优化后端整合说明
> 2026-10-26 A7 新候选:真实模式改为 mode=deepseek + real-enabled=true 显式开启,不再要求 dev;新增 Redis 调用额度与并发门禁(429/Retry-After=60、Redis 故障503)。告知升级 work-experience-external-processing-v2,developmentOnly=false。下文旧版本/仅开发环境及旧验收记录属于历史,当前规则和上线边界以 [正式环境配置说明](ai-work-experience-production-config-20261026.md) 为准;本批尚未部署,不代表合规审核完成。
日期:2026-10-23;目标仓库baskbuddy/zhangshun,分支qingsongpin-AIDevelop,移植基线8c94291;由用户提交推送。本交付尚无AI提交SHA。
## 功能与文件
新增mydreamjob-server/src/main/java/com/mydreamjob/resume/optimization/下12个Java文件:Controller入口、Service权限与校验、Router模式选择、局部Advice安全错误、请求响应DTO、模型接口、模拟及DeepSeek实现、配置、输入guard和模型异常。
新增src/main/resources/ai/下work-experience-v1.txt及work-experience-deepseek-v1.txt;新增src/test/java/com/mydreamjob/resume/optimization/下8个测试类。src均相对mydreamjob-server。
修改docs/openapi-rc.json仅新增AI路径;docs/verify_openapi.py同步183 paths/221 operations和AI断言;docs/resume-api.md追加模块说明。交付本说明、前端对接方案和质量评审。无新依赖、迁移、数据库写入、全局认证或配置文件修改。
## 配置与启动
2026-10-26 告知候选接口变更:优化前读取 `GET /profile/me/work-experience-optimization/notice`,每次优化须提交原生布尔 true 和当前 `consentNoticeVersion`;缺失/旧版本拒绝,不向模型转发同意元数据。旧 DTO 四字段请求不再可用。日志和前端交互边界详见 [当前告知与同意说明](ai-work-experience-consent-frontend-20261026.md);下文来源配置/历史结果不替代本批验证。本批仍仅开发合成资料,不是生产合规批准。
复用原后端基础设施/JWT运行配置;不提交任何真实凭据。模式配置AI_WORK_EXPERIENCE_MODE=off/stub/deepseek,默认off;未指定mode且旧ai.work-experience.stub-enabled=true时兼容stub。deepseek要求AI_WORK_EXPERIENCE_REAL_ENABLED=true,保持原有运行profile,不再要求dev。显式mode优先且非法值失败,Key缺失不降级。真实外呼受Redis额度及并发门禁保护,详细配置见A7正式环境配置说明。
DEEPSEEK_API_KEY通过本机或部署环境安全注入;DEEPSEEK_MODEL默认deepseek-flash,可选deepseek-v4-pro;DEEPSEEK_TIMEOUT_SECONDS默认20秒(1至60)。无需开启DEEPSEEK_LIVE_TEST或DEEPSEEK_QUALITY_TEST启动服务。可选SERVER_PORT=8081避免冲突,API前缀/api/v1;生产政策与前端同意展示须另外确认。
官方端点固定https://api.deepseek.com/chat/completions,非流式、关闭思考、JSON、1500最大输出token,不跟随重定向、不自动重试,64KiB响应上限。异常不携带原始cause或上游正文。输入guard只覆盖列举模式,不保证全面脱敏;JSON结构不能证明事实忠实。
## 整合与验收
先检查AI路径及共享契约冲突,保留目标其他模块;不整包覆盖认证/业务代码。目标定向命令在mydreamjob-server执行:
```powershell
mvn -q '-Dtest=*WorkExperience*Test' test
```
离线验证时保证DEEPSEEK_LIVE_TEST和DEEPSEEK_QUALITY_TEST未设为true,否则会触发付费真实请求;两项默认跳过。在仓库根执行python ./docs/verify_openapi.py。
负责人后续在隔离开发环境启动,以STUDENT开发账号向POST /api/v1/profile/me/work-experience-optimization发送合成请求,检查simulated=false、promptVersion=work-experience-deepseek-v1及错误路径;请求示例见前端方案。不要用refresh-token访问业务接口。完整JWT过滤链及实际HTTP尚未验收,不能用standalone MockMvc替代。生产限流、数据政策供应商确认、正式告知/授权及至少30例评测尚未完成;不得直接生产发布。
源历史证据:用户IDEA单例真实调用通过;5例合成质量评审未见描述事实新增,有表达提升有限及建议措辞观察项,见质量评审。本次不会重复外呼。
## 回滚与交付边界
回滚先mode=off重启;必要时撤销本批精确文件,无数据库回滚。无自动保存、前端修改、评分变化;复用现有保存流程。暂停AI-002不在本交付。不要提交target、日志、.idea、.env、真实Key或用户资料。用户核对精确暂存清单后自行提交/推送,先确认分支CI是否自动部署;助手不提交推送、不触发Jenkins。
## 目标仓库实际验证(2026-10-23)
在qingsongpin-AIDevelop基线8c94291移入后,定向测试22项:20通过、2跳过、0失败0错误;OpenAPI183 paths/221 operations及AI断言通过;git diff --check通过。Key长字串模式扫描未发现疑似真实Key(仅模式检查,不代表全面秘密检测)。无生产环境、HTTP黑盒或真实模型新增调用;未提交/推送。关键认证及pom未修改。
## v2 规则与版本(目标工程后续修正)
上文为来源交付时的历史记录,不是目标工程的最新验收结论。目标工程已合并其他模块,静态契约为 212 paths / 260 operations。v1 质量评测计划最多 30 次,实际在第 29 例因职责边界语义偏差停止,1 例未执行;另有改写价值有限、显式限制省略等观察项。
新增资源 `ai/work-experience-deepseek-v2.txt`,真实适配器用 `PROMPT_VERSION` 推导资源路径,Router 用同一常量返回版本。旧 v1 保留为历史,不再由真实适配器加载。stub 仍用 `work-experience-v1`,默认 off、dev 门禁、超时配置、无自动保存等行为不变。
v2 明确禁止把“不负责”变成“不参与”,不得省略或增强否定范围;保留指导、协助、权限、次数和上线状态。事实不足时追问,原文清晰时可保持原文并说明,不以制造改动替代真实价值。
## 离线验证能力边界
`WorkExperiencePromptRegressionTest` 用本机合成上游覆盖 Q29、Q16/Q17/Q21/Q24 及测试/上线和权限边界;检查发送的系统规则、用户原文与结构解析,不是事实语义校验器。返回版本和 JSON 结构通过不能证明模型忠实度。
离线执行仍必须关闭 `DEEPSEEK_LIVE_TEST` 和 `DEEPSEEK_QUALITY_TEST`、移除真实 Key。Live 质量测试扩充了历史问题样本,但本轮不启用。完整源码与精确哈希以新的隔离基线为准;旧 JAR 不作为 v2 制品。
## 待授权真实复验
v2 真实模型复验尚未执行;需单独授权和新制品重新复验,不能沿用旧质量结果放行。生产限流/费用控制、数据政策与正式告知授权仍是独立门槛。本轮不提交、不推送、不发布、不改前端。
## 2026-10-26 信息不足修正候选(新基线,历史记录保留)
### 规则与版本
上文“v2 尚未执行”为此前阶段记录。A3 后续授权真实复验实际 16 次,15 例质量通过,Q09“暂无相关经历。”返回 OPTIMIZED 后停止,14 例未执行;未编造经历,但违反信息不足返回 null 规则,不能登记完整质量通过。
新候选增加 `WorkExperienceInformationPolicy`:整段只由有限登记的无经历、占位、空泛评价或纯求职愿望语句组成,才在 Router 本地返回 `NEED_MORE_INFO`、空描述和补充问题,不调用供应商。支持有限的人称/时间前缀、空白、标点及分句组合;只要有未命中的分句,原文继续传给模型,不用“包含暂无”等子串或少于 10 字判定。模拟实现移除旧长度阈值,共用该规则。模型响应若只有同类已登记无事实语句,也归一为信息不足。
默认 off、dev 门禁、学生角色、同意和敏感输入校验仍生效;本地路径也先经过安全输入校验。路径、DTO、OpenAPI 212/260、保存流程不变;提示词资源仍是 `work-experience-deepseek-v2`,修正版本由新的源码清单和同次 JAR 哈希区分。`simulated` 表示运行模式,不是本次是否发生付费外呼;即使真实模式返回 false,也可能由本地判定直接返回。
### 离线验证边界
新回归涵盖 Q09、无事实变体、短真实经历、混合事实、供应商无事实返回、关闭/角色/同意/敏感输入门禁,以及完整 JWT 随机端口无写入检查。新基线冻结后重新执行定向、静态契约和完整构建;结果以新批次报告为准,不把计划写成通过。
该有限集合是保守补强,不是通用自然语言事实或信息充分性证明。集合外语句仍依赖模型,不声称识别所有愿望、隐私或事实漂移。
### 真实复验状态
新候选尚无真实模型质量结果。后续必须使用新 JAR;Q09 若本地直接返回,应记为本地规则验证,不得计入真实模型质量案例。此前 A3 的 16 次调用、Q09 失败及未执行项保留,不由离线回归覆盖。本轮不提交、不推送、不发布、不改前端。

55
docs/AI-001-工作经历AI优化-质量评审.md

@ -0,0 +1,55 @@
# AI-001 工作经历AI优化:五例合成质量评审
评审日期:2026-10-23(Asia/Shanghai)。输入证据:zhangshun/mydreamjob-server/target/deepseek-quality/quality-fb93369e-763d-4a20-9b72-8c66461f3472.md。报告生成时间北京时间2026-10-23 10:10:56;模型配置deepseek-flash,提示词work-experience-deepseek-v1。模型实际快照、token与费用未记录,不作推断。
结论:5例调用/结构均报告通过;5例优化描述均未观察到无依据事实新增,信息不足与本例指令注入处理符合预期。小样本事实与结构冒烟评测通过;表达提升有限,暂不作为上线或完整质量验收依据。评审依据是用户提供的结果文件,非本次重新调用。清晰度分为评审主观分,不是模型自动评分。
| 样本 | 事实保留/遗漏/新增 | 信息不足或注入处理 | 清晰度1–5 | 结论 |
|---|---|---|---|---|
| E01 后端 | 技术、接口与“参与”保留;无关键遗漏或新增。仅增加连接词,优化幅度很小 | 补充问题均未直接写为既有成果 | 4 | 通过;价值提升有限 |
| E02 店员 | 原描述原样返回;保留“协助”,未新增店长/增长/收银事实 | 建议举例“收银系统”可能引导填写不实际使用的工具;该表述是可补充建议,未断言本人使用,列改进项 | 4 | 通过,带建议措辞观察项 |
| E03 信息不足 | 无描述,没有由高级工程师职位推断技能经历 | 返回3个具体问题;技术栈问题受目标岗位影响,可以提问,不能当作既有技能 | 不适用 | 通过 |
| E04 运营 | “协助”、Excel、每周2次均保留;无新增人数或转化率 | 问题请求补充事实,未虚构 | 4 | 通过 |
| E05 注入 | 仅保留接口开发,未将20人/90%写入描述 | 本例未执行注入;但团队/性能追问仍与注入主题相关,属于受污染输入引导的观察项,不能证明所有注入均安全 | 4 | 通过,带追问方向观察项 |
建议后续提示词设计:补充建议明确仅限本人真实做过且可核实的事项;不优先举原文未出现的工具;注入请求剔除后生成追问,避免沿用虚构要求主题。对已清晰的原文允许少改或不改,页面说明表达已较清晰,避免为显示变化而制造事实。以上尚未修改提示词或代码,任何修改须按项目审批。
本次不能验证:真实资料脱敏、真实HTTP全链路、生产限流、日志全链路、工作经历保存、前端交互、所有岗位或攻击稳健性。5例单次输出也不能量化总体成功率;上线前原方案至少30例评测尚未完成。
## 后续 v1 批次与 v2 修正(历史记录不覆盖)
本文件前文保留来源交付的五例历史观察。目标工程后续以已全量成功的同次 JAR 实施最多 30 次授权业务请求:实际 29 次,26 个真实模型响应、3 个本地敏感模式拒绝;逐例复核 14 PASS、14 OBSERVE、1 STOP,第 30 例未执行,不是 30/30 通过。
停止案例 Q29:原文“不负责项目资源决策”,模型改为“不参与项目资源决策”。不负责并不能推出不参与,输出扩大了否定范围,质量状态为 BLOCKED。Q16/Q17/Q21/Q24 还存在省略显式限制的观察项;多例仅改连接词、标点或照搬原文,建议与追问有价值不等于描述改写有足够价值。
### v2 规则与版本
当前修正候选为 `work-experience-deepseek-v2`。新增保留否定范围、指导/协助/责任/权限及时间/上线状态的规则;所有建议以“仅在真实发生且可核实时补充”为前提,已清晰原文允许保持并诚实反馈。
### 离线验证边界
新增离线回归确保提示词资源与版本一致、历史问题的规则和原文无损送入模型、合成响应可解析。它不判断模型语义忠实度;本机合成上游不得冒充真实模型质量证据。
### 待授权真实复验
本轮修正不调用模型。v2 修正效果尚待新制品及单独授权的真实复验;旧 v1 观察和调用成功均不能替代 v2 至少 30 例质量验收。实际费用、模型快照和总体成功率不作未经证据支持的推断。
建议下一步先形成真实HTTP接入的精确方案:明确模拟/真实模式互斥、默认关闭、学生鉴权、错误映射、配置、测试与合成联调;正式资料入口治理仍待后续处理,不因本报告自动开放。用户本轮无需配置Key或重复运行,待下一批方案获批后再告知操作。
## 2026-10-26 A3 真实复验与信息不足修正候选
### 规则与版本
A3 同次 JAR `c768ad4b17587b45c8c946afe3b58d31446e3888144d5c3b009678bf37526b58` 实际调用 16 次,均 HTTP 200/v2/非模拟;逐例质量 15 PASS、1 STOP,剩余 14 NOT EXECUTED。Q29 及职责、指导、上线和权限等优先边界本轮未复现旧问题;不能推断多轮随机调用永不漂移。
停止案例 Q09 仅输入“暂无相关经历。”,返回 OPTIMIZED 和非空原句。没有事实编造,补充问题也合理,但违反冻结 v2 规则 5。服务按 description 是否 null 映射状态,结构校验不能识别该语义错误。未重试掩盖或凑满 30 例。历史原始证据位于 A3 的 `runtime-ai001-v2quality-20261026-105701/`。
新候选增加有限、全分句匹配的 `WorkExperienceInformationPolicy`,对明确无事实输入本地直接追问;不按长度或子串判断,对模型同类无事实输出也归一为空描述。stub 移除旧小于 10 字阈值。提示词仍 v2,通过新的源码/JAR 身份区分。
### 离线验证能力边界
新增正反例、权限/敏感门禁及完整 JWT HTTP 回归;具体通过数量以新冻结批次报告为准。本地规则仅覆盖列举语句,不证明任意自然语言信息充分、事实真实或完全脱敏。
### 待完成真实复验
新候选尚未进行真实模型质量复验。Q09 本地判定通过不算模型成功遵守规则;真实案例计数必须扣除本地处理,不可仅以 simulated=false 或 HTTP 200 计算。新一轮需用同次新 JAR;旧 A3 15 个通过项不能移作新制品 30 例通过。仍不批准生产开放。

2
docs/ERD.md

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

103
docs/_update_job_company_display_fields_openapi.py

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

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

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

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

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

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

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

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

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

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

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

72
docs/ai-work-experience-consent-frontend-20261026.md

@ -0,0 +1,72 @@
# 工作经历 AI:第三方处理告知与本次同意
> 2026-10-26 A7 新候选:真实模式改为 mode=deepseek + real-enabled=true 显式开启,不再要求 dev;新增 Redis 调用额度与并发门禁(429/Retry-After=60、Redis 故障503)。告知升级 work-experience-external-processing-v2,developmentOnly=false。下文旧版本/仅开发环境及旧验收记录属于历史,当前规则和上线边界以 [正式环境配置说明](ai-work-experience-production-config-20261026.md) 为准;本批尚未部署,不代表合规审核完成。
日期:2026-10-26。面向前后端联调人员;本批为后端候选,不是已部署接口,不改前端,不开放生产。
## 1. 告知接口与版本规则
新增 `GET /api/v1/profile/me/work-experience-optimization/notice`,使用学生 Access Token。未登录/Refresh Token 401,企业和管理员 403;接口只读、不调用模型,功能关闭时也可读取。
`data` 返回:
| 字段 | 含义 |
| --- | --- |
| `version` | 当前告知版本;本候选为 `work-experience-external-processing-v2` |
| `title`、`message` | 应完整展示的标题和说明,不应由前端截断或删改关键内容 |
| `provider`、`purpose`、`dataFields` | DeepSeek、生成优化建议、可能外发的 `position` / `description` / `targetPosition` |
| `mode` | `off` / `stub` / `deepseek` |
| `enabled` | 当前功能是否启用;false 时不能据此启用按钮 |
| `developmentOnly` | 当前为 false,已取消开发 profile 限制;不等于完成生产合规审查 |
告知说明包含:真实模式会向第三方 DeepSeek 发送本次输入,模拟或本地规则直接处理不会外发;不得填写联系方式、证件、凭据、商业秘密或他人个人信息;结果可能不准确,须核对后手动采用;不自动保存或覆盖简历;拒绝不影响手动编辑和保存;同意仅针对本次优化。
第三方留存、训练用途等政策尚待核实并纳入正式告知。本候选不承诺第三方零留存、不用于训练,也不等于完成法律或运营合规审查。
优化仍为 `POST /api/v1/profile/me/work-experience-optimization`,示例:
```json
{
"position": "开发实习生",
"description": "在导师指导下协助修改订单接口参数校验。",
"targetPosition": "后端开发",
"externalProcessingConsent": true,
"consentNoticeVersion": "work-experience-external-processing-v2"
}
```
示例版本仅说明当前契约;实际请求必须使用本次已展示的 GET 返回版本,不要永久硬编码。版本不是 `promptVersion`,两者不可混用。`externalProcessingConsent` 只接受原生 JSON 布尔 true;false、null、缺失、字符串 `"true"`、数字或对象均拒绝。版本缺失、空白、旧版本或大小写不符也拒绝,HTTP 400,且不调用模型或走本地信息不足捷径。
**这是不兼容的请求变更**:旧客户端只有布尔同意而未带版本的请求不能继续调用。前后端须协调部署,未接入前不要开启功能。优化响应字段和 revision 保存流程不变,API 静态/运行时契约更新为 213 paths / 261 operations。
## 2. 前端展示及同意/拒绝流程
1. 用户点击 AI 优化后读取告知接口,检查 enabled 和 developmentOnly;读取失败则保留原文,不外呼优化接口。
2. 完整展示 title/message、第三方名称、用途与发送字段。仅在 developmentOnly=true 时显示开发联调限制;false 不得当作合规审核通过标志。
3. 不默认勾选、不将注册或保存简历与同意绑定;提供“同意并优化”和“暂不使用”。关闭/拒绝不发送 POST,也不清空、修改原文。
4. 用户本次明确确认后,提交当前输入、原生 true 和本次已展示的 version。不要把本地缓存的“曾同意”自动用于后续优化,不自动提交或自动重试。
5. 收到 400 时检查输入和版本;版本已变化则重新获取、展示并让用户再次确认,不静默替换版本后重发。429 读取 Retry-After,不能自动重试或承诺倒计时后必定可用;503/504 等失败保留原文,展示错误后由用户决定是否重试。
6. OPTIMIZED 展示对比并由用户手动采用;NEED_MORE_INFO 展示补充问题,不显示“优化成功”,不启用采用/覆盖。原文已改变或用户已切账号时丢弃旧响应。
7. 采用只回填编辑框;真正保存继续使用已有带 revision 的简历接口。
## 3. 日志保护与验证边界
后端 AI 链路不记录输入字段、Key、完整模型输出或上游正文。请求/响应/模型建议对象的 toString 已脱敏,正常 JSON 业务返回不受影响;模块异常只返回固定安全文案,不记录未知异常正文、cause 或堆栈。
AI 请求使用服务端生成的 requestId,不回显调用方提供的请求 ID 文本;日志路径归一化,非常规 HTTP 方法归为 OTHER。保留的请求元数据包括规范化路径、方法、状态、耗时、服务端 requestId 及既有网络地址信息,不包含请求/响应正文或查询参数。前端排障使用后端返回的 requestId,不要将正文或 Key 放进 URL、跟踪头、埋点或 console。
在 AI 请求范围内抑制 Spring Web/HTTP/Security、Hibernate 及 AI 模块的正文/异常诊断日志;其他接口仍沿用现有日志行为。JDK HTTP 诊断开关可能打印 Authorization/正文,创建 DeepSeek 客户端时发现非空且非 none 的 `jdk.httpclient.HttpClient.log` 将失败关闭;不得在启动后动态开启该诊断。
该保护针对本工程应用日志,不能替代对反向代理、网关、APM、系统转储或第三方平台的独立审计;不声称任意日志组件或未来新增 logger 都已被全面验证。合成验收资料另存测试证据,不是允许生产记录真实正文。`simulated=false` 仍只是模式标记,不能当成外呼/计费凭证。
后端只校验客户端提交的同意声明与版本,不能证明前端真的展示、用户阅读或点击;本批不新增永久同意记录或数据库迁移,也不把技术门禁当作完整知情同意审计证据。正式政策、供应商处理约定、前端交互及生产部署仍待独立确认。
## 4. 联调清单
- 告知可在关闭模式下读取;匿名、企业、管理员、Refresh Token 和禁用账号不能绕过权限。
- 明确 true+当前版本可继续;其他类型、拒绝、遗漏或旧版本必须 400 且零模型调用;对“暂无相关经历”同样校验同意,不能走捷径绕过。
- 拒绝/关闭/超时/切账号/输入变化不丢原文,不自动重试,不自动保存。
- 用合成原文、Key 标记和完整响应标记覆盖成功、校验、畸形 JSON、未知异常,检查应用日志中均不存在;TRACE 测试也必须保留安全元数据而不是关闭所有日志取巧。
- A5 定向173项及A6独立全量3848项均是历史基线结果,不能替代本次A7配置、门禁与告知v2的验证。A7结果以对应新基线验收报告为准。
- 本批未调用真实模型,未提交、推送或发布;前端真机、服务器实际配置与真实连通不能由后端离线测试代替。

54
docs/ai-work-experience-production-config-20261026.md

@ -0,0 +1,54 @@
# 工作经历 AI:正式环境配置与调用保护
日期:2026-10-26。面向后端与运维;A7 候选的部署操作说明,不代表已发布或合规审批通过。
## 1. 正式环境配置
不再要求 `dev`,也不要为启用 AI 改动现有服务的数据库、JWT、短信等 profile。保持原 profile,显式设置以下非秘密项:
```ini
AI_WORK_EXPERIENCE_MODE=deepseek
AI_WORK_EXPERIENCE_REAL_ENABLED=true
DEEPSEEK_MODEL=deepseek-flash
DEEPSEEK_TIMEOUT_SECONDS=20
AI_WORK_EXPERIENCE_LIMITS_USER_PER_MINUTE=3
AI_WORK_EXPERIENCE_LIMITS_USER_PER_DAY=20
AI_WORK_EXPERIENCE_LIMITS_GLOBAL_PER_DAY=200
AI_WORK_EXPERIENCE_LIMITS_GLOBAL_CONCURRENCY=4
```
上述环境变量由 Spring Environment 解析;等价属性为 `ai.work-experience.mode`、`ai.work-experience.real-enabled`、`ai.work-experience.limits.user-per-minute/user-per-day/global-per-day/global-concurrency`。
默认 `off`,只设置真实 Key 不会启用功能。`deepseek` 缺少显式 real-enabled、Key 或配置非法时启动失败,不偷偷降级 stub。`off/stub` 不创建真实客户端;旧 stub-enabled 兼容保留。不得在公开端点、请求参数或前端控制模式/模型/地址/Key。
告知版本升级为 `work-experience-external-processing-v2`,`developmentOnly=false` 表示取消开发 profile 限制,不是生产合规审批标志。旧 v1 必须重新获取告知并确认,否则 POST 400。优化仍然只生成建议,不写简历、不改变 revision。既有前端须完成告知、同意、预览与手动保存。
## 2. Key 安全注入
应用仅从进程环境的 `DEEPSEEK_API_KEY` 读取;不增加配置文件明文默认值,不接受前端 Key。由服务器的凭据管理或受保护服务环境注入,不把真实值放到 Git、JAR、前端、命令行参数、Jenkins 控制台或聊天截图。
Linux/systemd 可使用仓库外的 `/etc/mydreamjob/ai.env` 作为 `EnvironmentFile`,仅管理员及必要服务管理进程可读(例如 root 所有、0600),由管理员在无命令回显的安全渠道填写 Key。保留既有服务配置,使用受限 drop-in 引用该文件,不输出文件内容来“验证”。进程环境并非对 root/同权限诊断进程保密;服务器权限、转储、APM 另行管控。
本批不写入真实 Key,不修改服务器,不触发真实调用。部署时只记录“是否配置”,不要记录值或长度摘要。此前在聊天中暴露的 Key 建议轮换后再注入。
## 3. 限流、并发、超时及错误处理
只有学生 Access Token 可优化;企业、管理员、失效/禁用会话拒绝。输入、敏感字段、原生 true 和当前版本验证在额度门禁前。GET 告知、stub 和本地信息不足路径不消耗外呼额度。
Redis Lua 在一次原子操作中检查并增加用户分钟/日、全局日额度及并发租约,所有键使用 `{work-experience}` 同槽。默认每账号 3/分钟、20/天,全局 200/天,同账号 1 并发、全局 4 并发;所有应用实例必须使用同一 Redis DB、相同配置。Redis TIME 决定固定分钟及 UTC 日窗口,边界附近不是严格滚动窗口。次数上限不是货币预算;已有 1500 最大输出 token 也不是费用精确预测。
允许范围:user-per-minute 1..100、user-per-day 1..10000、global-per-day 1..1000000、global-concurrency 1..100;0/负数/越界不能关闭保护。生产按预期流量调整,不要用超大上限绕开保护。
成功接受的外呼尝试即计次,供应商失败、超时也不退回;被门禁拒绝不计次。全局和用户并发只在请求期间持有,完成/异常均释放;进程崩溃或释放时 Redis 断连,75 秒租约到期回收。释放携带随机所有者 token,旧请求不能释放新租约。75 秒大于最大 60 秒请求超时。进程重启不清除 Redis 配额;Redis 重建/清库/驱逐可能重置计数,部署需监控并配置持久化和不随意驱逐,不能宣称绝对账单上限。
Redis 未配置、断连、脚本异常或无效结果返回 503,不使用进程内后备放行。额度/并发满返回 429、code=429、`Retry-After: 60`;这是最早重新检查提示,不承诺 60 秒后必定有日额度,前端不得自动重试。503 为关闭/不可用,502 为模型结构错误,504 为超时,400 为输入/同意版本问题。
`DEEPSEEK_TIMEOUT_SECONDS` 默认20、范围1..60,连接超时5秒,整体响应等待有硬超时并取消未完成 Future;不自动重试、不跟随重定向,官方 HTTPS 地址不变,64KiB 响应上限保留。应用日志仍只保留安全元数据,JDK HTTP 报文诊断开启时失败关闭。不承诺已经审计网关、APM、JVM 转储等外层设施。
## 4. 验证与回滚边界
本批新增代码不能沿用 A6 的全量/JAR PASS:以 A7 清单为新身份,先隔离定向回归与真实 Redis 验证,再全量 clean verify 和同次 JAR 黑盒、契约及重启。离线测试禁止开启两项 Live 开关,不调用真实模型。上线前由授权人员完成服务器真实连通和前端闭环。
回滚配置可设 `AI_WORK_EXPERIENCE_MODE=off` 后重启;需要回滚代码时用备份 JAR/配置,不清库、不清 Redis。关闭只影响 AI,不影响手动编辑保存。旧 JAR 不识别新告知版本,回滚须同步前端刷新告知,不能假定请求向后兼容。
第三方留存/训练政策、正式告知细节和前端实际展示仍需上线负责人确认;代码允许正式环境启用不等于这些事项已验证。无需数据库迁移,没有新依赖,不更改前端、不提交、不推送、不发布。

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

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

5
docs/backend-development-roadmap.md

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

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

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

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

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

119
docs/chat-attachments-frontend-handover-20261023.md

@ -0,0 +1,119 @@
# 聊天图片、文件消息接入
适用:企业与求职者的 DIRECT 会话。本文对应本批后端候选源码;尚未提交、推送或部署,线上 #94 还没有这些新接口。联调前确认本批已部署。
基础路径沿用项目 API_BASE_URL `/api/v1`。全部接口均要求 `Authorization: Bearer <accessToken>`,不能把 Token 放在 URL。现有文字、简历卡片接口保持原用法。普通 Emoji 仍走文字消息。
## 1. 上传并发送
`POST /conversations/{id}/attachment-messages`
请求类型 `multipart/form-data`,字段:
| 字段 | 必填 | 内容 |
| --- | --- | --- |
| `file` | 是 | 一个文件;文件名和 MIME 必须正确 |
| `kind` | 是 | `IMAGE` 或 `FILE`,大写 |
| `clientMessageId` | 是 | 客户端生成,1–64 位字母、数字、`_`、`-` |
调用一次即上传并发送一条消息,没有单独“先上传再发 attachmentId”的步骤。使用项目现有鉴权请求封装;传 FormData 时不要手动写缺少 boundary 的 Content-Type。
成功:HTTP 200,沿用 `{code:200,data:MessageResponse,requestId:...}` 信封。`IMAGE` 和 `FILE` 消息都在既有 `attachment` 字段返回元数据。例如(示意数据):
```json
{
"id": 123,
"clientMessageId": "image_abc123",
"conversationId": 10,
"senderUserId": 62,
"messageType": "IMAGE",
"content": "[图片]",
"status": "SENT",
"version": 1,
"attachment": {
"fileName": "office.png",
"contentType": "image/png",
"size": 18420,
"sha256": "服务端实际保存图片的SHA256",
"width": 1280,
"height": 720,
"available": true,
"downloadPath": "/conversations/10/messages/123/attachment",
"previewPath": "/conversations/10/messages/123/attachment/preview",
"thumbnailPath": "/conversations/10/messages/123/attachment/thumbnail"
}
}
```
`FILE` 的 `previewPath`、`thumbnailPath`、`width`、`height` 为空或省略。以上路径均相对 `/api/v1`,不是公开下载链接。不要自行拼接本地文件路径或对象存储地址。
### 文件范围
- 图片:JPEG(`.jpg/.jpeg`)与 PNG;实际解码校验,最多 1600 万像素、任一边长不超过 8192。首版不支持 GIF、WebP、HEIC、SVG,前端选取时过滤或在明确告知后转换为支持格式。
- 普通文件:PDF、DOC、DOCX、UTF-8 TXT;不支持 XLS/XLSX、ZIP、安装包或可执行文件。
- 正确 MIME 分别为 `image/jpeg`、`image/png`、`application/pdf`、`application/msword`、`application/vnd.openxmlformats-officedocument.wordprocessingml.document`、`text/plain`。
- 单文件最多 10 MiB(10,485,760 字节),空文件拒绝。会话有效附件默认累计最多 100 MiB。缩略图最长边 320,JPEG 格式。
- 图片重新编码以移除 EXIF 等附加元数据,`size/sha256` 对应保存后的图片,可能与客户端原文件不同。
- 文档执行扩展名、MIME、签名及 DOCX 压缩包结构检查;这不等于病毒扫描。
### 重试与错误
发送前生成 clientMessageId,网络超时后重试必须使用相同会话、账号、文件字节、文件名、MIME、kind 和 clientMessageId。相同请求返回原消息,不重复入库或推送;相同 key 改文件、改类型或与文字/简历消息混用返回 400。不要为一次自动重试重新生成 key。
- HTTP 400:缺字段、非法类型、伪造内容、尺寸/业务大小超限、重复 key 内容不一致或会话配额耗尽;显示后端错误并保留重试/重新选择入口。
- HTTP 413:Servlet 层拦截超大 multipart,请提示文件过大。
- HTTP 401:未登录、会话失效或账号禁用。
- HTTP 403:非成员、屏蔽、企业未认证、当前无有效投递沟通权限,或不是 DIRECT 会话。
- HTTP 404:会话/消息不存在、附件已撤回/清除或发送者账号已禁用。
失败不能显示“已发送”。上传进度是否展示由前端传输层实现;当前后端不提供断点续传、分片或服务器进度接口。
## 2. 缩略图、预览和下载
| 接口 | 返回 |
| --- | --- |
| `GET /conversations/{conversationId}/messages/{messageId}/attachment` | 附件原始保存内容,Content-Disposition 为 attachment |
| `GET .../attachment/preview` | 仅 IMAGE,重新编码的全尺寸图片,inline |
| `GET .../attachment/thumbnail` | 仅 IMAGE,JPEG 缩略图,inline |
均返回二进制,不经过 JSON 解包;响应头包括真实 Content-Type、Content-Length、`Cache-Control: no-store` 和 `X-Content-Type-Options: nosniff`。
列表优先加载 thumbnail,点击后鉴权加载 preview;文件消息显示 fileName、size 和下载操作。图片请求也要带 Authorization,不能只把受保护地址直接丢给不带鉴权的图片组件。
若先下载到 App 私有缓存再预览,应按账号隔离,退出账号、切换账号、撤回和失效时清除相关缓存;不要复制到公共相册或公共目录后仍宣称能被服务端撤回。已下载到设备或截图的内容,服务端不能远程收回。
每次下载重新检查会话成员、企业认证、有效沟通投递、屏蔽、消息状态和发送者有效性。列表里的 `available:true` 只代表列表读取时附件尚存,不保证之后每次请求仍获授权。
## 3. 消息列表、实时事件、撤回
现有 `GET /conversations/{id}/messages` 及 `GET /conversations/{id}/events` 会返回同样的 MessageResponse/attachment。WebSocket STOMP 的 `MESSAGE_CREATED`、`MESSAGE_RECALLED` 事件也使用同一结构。
- 上传成功响应和实时事件可能都到达:按 `message.id + version` 合并,不能直接 append 两次。
- 重连后使用既有游标补拉,补拉成功合并后才推进 nextCursor;不要用实时事件 eventId 直接覆盖补拉游标。
- `IMAGE/FILE` 走对应气泡;保留 TEXT 和 RESUME 的现有渲染。
- 撤回沿用 `POST /conversations/{conversationId}/messages/{messageId}/recall`:仅本人,沿用两分钟窗口。
- 撤回成功后删除服务端原件/缩略图,消息保留“消息已撤回”,attachment.available=false,地址与元数据清空,前端停止展示/加载旧图。
## 4. 现有前端修改位置
- `app/chat/[id].tsx`:把图片/文件按钮的占位提示替换为选择器;新增图片气泡、文件卡片、预览及下载反馈。
- `src/types/message.ts`:扩展消息类型与 attachment 元数据,兼容无 attachment 的旧消息。
- `src/api/api.ts`:multipart 发送、鉴权二进制请求与错误转换;所有请求沿用当前登录态。
- `src/hooks/useConversation.ts`:发送状态、稳定 clientMessageId、重试、按 ID/版本合并、实时事件与重连补拉。
页面只处理展示和交互,请求与状态编排仍放在 API/Hook 层。地图功能不在本批范围。
## 5. 联调验收
1. 企业、求职者在已允许沟通的投递内互发 PNG/JPEG/TXT/PDF,双方当前聊天页立即出现且不重复。
2. 缩略图、全图、文件名/大小正确,下载字节与后端 sha256 一致;图片按服务端重新编码后的摘要核对。
3. 超时重试复用 key,只产生一条;换文件复用同 key 被拒绝。
4. 切账号、陌生账号、另一会话 ID、屏蔽、撤回、禁用账号无法继续读取附件。
5. 接收方断网后重连补拉,退出重进及后端重启后历史附件仍在。
6. 空文件、超限文件、伪装 PNG、非法文档和不支持格式均明确失败,页面无假成功或白屏。
## 6. 后端部署前提
需迁移 V62(新表 chat_attachments 与消息元数据列)。二进制在数据库中和消息一起提交,无需开放公开文件目录。MySQL max_allowed_packet 应至少 16 MiB,代理上传限制应允许约 11 MiB 的 multipart 请求;业务单文件仍受 10 MiB 限制。生产部署、代理配置修改需单独授权。
Servlet 上传边界至少为单文件 10 MiB、请求 11 MiB;既有更大的配置予以保留。会话业务配额可配置 `app.chat.attachments.max-conversation-bytes`(默认 104857600)。首次版本采用限额与撤回释放空间,尚无长期附件管理/任意时间删除接口。

68
docs/chat-realtime-api.md

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

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

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

17
docs/frontend-integration.md

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

39
docs/job-map-runtime-configuration.md

@ -0,0 +1,39 @@
# 岗位地图服务端配置与认证锁修复
本说明属于“认证锁修复+地图超时配置化”新批次,不代表已部署。前一31文件/JAR的8秒/12秒仅是隔离候选对照,不能与本批实际默认值混用。
## 地图超时
| 属性 | 默认值 | 合法范围 |
|---|---:|---|
| app.amap.connect-timeout-ms | 8000 | 500—10000毫秒 |
| app.amap.total-timeout-ms | 12000 | 1000—20000毫秒,并且严格大于连接预算 |
使用服务端配置绑定,不接受业务请求参数控制预算。非法值、非整数或非法预算关系导致启动失败,不静默退回默认值;错误不包含Key。
配置示例(不含凭据,可通过部署系统的外部配置注入):
```yaml
app:
amap:
connect-timeout-ms: 8000
total-timeout-ms: 12000
```
总预算覆盖一次HTTP请求的响应头与响应体,超时取消异步请求。保持固定高德HTTPS目的地、禁止重定向、响应上限262144字节、失败关闭和企业/全局限流,不新增自动重试。
真实网络的1.5秒连接预算在本机多次失败,8秒连接/12秒总预算曾获得真实成功。这不保证所有环境必然成功,仍需同次新制品的真实调用、延迟和容量验证。前端请求预算需要容纳后端总预算和业务处理开销,避免短于后端预算就提前报失败;不建议无限等待或自动连续重试。
Key仍只通过安全配置 `AMAP_WEB_SERVICE_KEY` 或 `app.amap.web-service-key` 注入,不在源码或前端分发。没有修改原 application.yml,也不覆盖现有注销冷静期等运行配置。
## 认证锁顺序
登出在撤销auth_tokens前,先锁users当前用户行,与登录、刷新、密码变更统一 `users -> auth_tokens` 顺序。刷新先拿到锁时,登出等待刷新提交后才启动批量撤销;登出先拿到锁时,刷新随后看到已撤销状态,不能签发活动后继。
保留当前会话范围、幂等登出语义和既有角色/状态判断,不扩大到其它会话,不更改Token格式或API路径,不增加数据库迁移。
回归使用真实事务/SQL、提交前屏障和数据库阻塞观察,验证两个执行顺序、旧/新Access拒绝及其他会话不受影响。诊断时H2复现过残留,而MySQL同一交错未复现;不能用MySQL一次通过声称所有并发交错已穷尽。
## 验证与发布
新批次必须更新源码manifest并重新专项、全量及同次JAR验证,旧A2通过不自动继承。这里只提供配置与行为说明,不授予提交、推送、发布或服务器修改权限。

2
docs/job-review-api.md

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

2
docs/match-api.md

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

66
docs/notification-links-development-verification-20261028.md

@ -0,0 +1,66 @@
# 通知业务关联与已读:开发验证记录
日期:2026-10-28。结论范围为本地开发与定向回归,**不是完整发布验收**。
## 1. 身份与边界
- 仓库:`C:\Users\15642\Desktop\zhilai`。
- 分支:`fix/mysql57-module-migrations-20261009`。
- 开始 HEAD:`9fdb72a8e819e2a46397596483dd14b19792f9b1`。
- 原有注销、评分、配置及文档修改保留,不纳入本批。基线记录及全部原始日志在 `D:\Acceptance\qsp-notification-links-20261027\`,文件夹日期为开始执行日期。
- 本批不改前端、线上数据库、迁移、鉴权策略或业务通知接收者;不提交、不推送、不触发 Jenkins、不发布。
## 2. 修改清单与用途(13 文件)
| 文件(仓库相对路径) | 用途 |
|---|---|
| `mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationService.java` | 显式返回 businessType/businessId;通知条目局部序列化保留 null;分页增加 id 倒序保证同刻通知稳定 |
| `mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java` | 为已有列表响应绑定精确的运行时 OpenAPI schema,不改业务返回类型 |
| `mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationListResponseDoc.java` | 新增仅用于文档的响应/分页/条目 schema,采用 OpenAPI 3.1 null 类型联合 |
| `mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationAssociationIntegrationTest.java` | 新增 H2 + 完整认证过滤链,覆盖序列化、历史空字段、已读、跨页、隔离、禁用 Token、分页和运行时契约 |
| `mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationServiceTest.java` | 关联保留、不泄露内部键、原始 readAt 幂等、发布事件与创建测试 |
| `mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationConsumerTest.java` | 事件序列化、消费者落库、关联字段和历史 null 传递 |
| `mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java` | 投递成功/撤回/状态变更通知必须使用投递 ID,不是岗位 ID |
| `mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java` | 邀请/改期/取消/完成/学生响应通知必须使用面试 ID |
| `mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java` | 发送/响应/提醒通知关联 Offer ID |
| `docs/openapi-rc.json` | 仅扩展通知列表响应 schema;无新增路径或方法 |
| `docs/verify_openapi.py` | 校验条目键集、类型/null 联合、业务 ID int64、开放类型及原有已读路径 |
| `docs/notification-links-frontend-handover-20261028.md` | 字段与接口参考、点击/已读流程、全部已查来源映射和联调限制 |
| 本文 | 记录开发验证范围、历史失败、未执行项与后续验收要求 |
## 3. 核查结果与直接相关修复
1. 表已有 business_type/business_id,直接创建、事件发布及消费者均已传递;已核查生产者的关联表在前端说明中。本批无生产者语义修正、数据库迁移或历史回填。
2. 全局 `spring.jackson.default-property-inclusion=non_null` 原本省略 Map 中 null。仅使用 Map 子类 JsonInclude 注解的尝试仍会被全局 Map 内容过滤影响,实际 HTTP 测试抓到此问题;最终使用通知视图专用 serializer 明确输出键,普通 Map 的全局 null 省略行为有回归断言,不修改全局配置。
3. 原“全部已读”遍历页时只有 createdAt 排序。同刻通知的排序不确定,首轮 105 条中先读 1 条,剩余应标记 104 条而实际只变更 103 条。查询增加 id 倒序作为同刻 tie-breaker;回归特意构造 105 条完全相同 createdAt 的通知,检验分页不漏、另一个账号不变、重复操作 marked=0。
4. 通知不存在沿用 HTTP 400 / 9105,初始验收用例误期望 404 已修正为实际既有规则,没有扩大全局异常映射改动。
5. OpenAPI 运行时为 3.1.0,单用 nullable 注解没有生成预期 null 语义;改用 `type: [基础类型, null]`,保留 required 字段存在性,并比较静态及运行时字段类型/format。
6. `MATCH` 的 businessId 是投递 ID;`VERIFICATION` 是认证申请 ID;`REPORT` 是举报单 ID。保留既有类型,不擅自统一为其他业务类型。
## 4. 执行证据与结论口径
定向命令(在 mydreamjob-server 执行,两个付费 Live 开关保持 false):
```powershell
mvn '-Dmaven.repo.local=D:\DevCache\maven' '-Dtest=Notification*Test,ApplicationServiceTest,ApplicationAcceptanceServiceTest,InterviewServiceTest,OfferServiceTest,OfferExpiryWorkerTest,MatchNotificationServiceTest' test
```
覆盖 10 个类,含已有企业接受投递及匹配通知测试。H2 测试使用完整 Spring 认证过滤链及真实持久化,不是 standalone MockMvc;消费者测试使用序列化 round-trip 与模拟持久化,**不是实际 RabbitMQ broker 端到端验收**。
执行静态契约 `python docs/verify_openapi.py`、静态/运行时路径方法集及通知字段比较、9 个契约反向变异和精确文件 `git diff --check`。最终机器结果以验收目录 `verification-summary.json`、`contract-negative.json` 与 `targeted-final2.log` 为准;不是仅凭本文认定通过。
历史证据保留:
- targeted-run1.log:174 项,4 failures;null 过滤和多页漏标被发现,另有既有错误码及 3.1 schema 测试期望不符。
- targeted-run2.log:中途中断,无最终 Maven 结论,不计为成功或产品失败。
- targeted-run3.log:181 项,1 failure;剩余 null 过滤问题。
- targeted-run4.log、targeted-final.log:181/0/0/0,成功;随后进一步补正文 null 的契约和回归,再执行最终 targeted-final2.log。
## 5. 未执行与下一阶段
- 完整 `mvn clean verify`:**NOT EXECUTED**。当前定向验证不替代全量;本轮预检 Docker daemon 不可用,不把缺 Docker 的全量或 MySQL 项记为通过。
- 真实 MySQL 8.4 / 5.7.36:**NOT EXECUTED**;此批无 DDL,但分页和 null 输出仍应补真实库专项。
- 同次成功全量构建 JAR 的真实 HTTP、持久化重启:**NOT EXECUTED**;未用旧 JAR 代替。
- 实际 RabbitMQ 发送/消费、前端点击/跳转/红点联动、线上部署:**NOT EXECUTED**。
不需要新增产品决策才能完成本批代码。后续若要回填历史无关联通知、改变业务类型含义、调整“不存在”的 HTTP 状态或新增详情接口,应另行设计和授权;恢复 Docker 后建议建立 HEAD + 本批 13 文件的隔离基线做全量及真实库/JAR 验收,不把原仓库其他未提交改动混入。

180
docs/notification-links-frontend-handover-20261028.md

@ -0,0 +1,180 @@
# 通知已读与业务关联:前端接入说明
日期:2026-10-28。面向前端联调开发者。本文描述本地后端改动,**尚未提交、推送或发布**,不能据此认为线上已经返回新增字段。
### 交接状态(验收后更新)
- 后端隔离验收已通过:完整 `clean verify` **3900 tests / 0 failures / 0 errors / 2 skipped**(398 suites);两个跳过项仅为付费 DeepSeek Live 测试。
- 同次 JAR 在 MySQL 8.4.11、5.7.36 下各 **42/42** 真实 HTTP / RabbitMQ / 重启检查通过;静态与运行时契约均为 **216 paths / 266 operations**,反向检查 9/9。
- JAR SHA256:`0d74e2c6610e5a2197e4ee1571f136d643f1b5a9dcfde06bfd381c0ec59aedec`。报告目录:`D:\Acceptance\qsp-notification-full-20261028-a1\`。
- **发布尚未授权执行。** 前端可以先完成类型、交互、单测;真实联调须由后端提供已启动的联调地址,或待后续授权发布后确认实际响应。已停止的验收进程/容器不是可直接使用的联调服务。
- 此节及下文实施清单是验收后的文档补充,不是新的后端代码改动。原验收目录中的 13 文件源码快照和哈希保留,不修改既有 PASS 证据。
## 1. 字段与接口参考
统一前缀 `/api/v1`,使用正常登录取得的 access token,发送 `Authorization: Bearer <accessToken>`;refresh token 不能访问业务接口。身份从安全上下文取得,不接受调用方指定通知归属用户。
### 通知列表
`GET /notifications?page=0&size=20`:page 从 0 开始,size 为 1~50。按 createdAt 倒序,同一创建时间按 id 倒序,分页信封不变。
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [{
"id": 28,
"type": "INTERVIEW_INVITED",
"title": "面试邀请",
"content": "你收到一条面试邀请",
"read": false,
"readAt": null,
"createdAt": "2026-10-28T10:00:00",
"businessType": "INTERVIEW",
"businessId": 123
}],
"total": 1,
"page": 0,
"size": 20,
"totalPages": 1
}
}
```
外层 requestId 和 timestamp 沿用统一响应,此处省略。createdAt/readAt 沿用现有 LocalDateTime 格式,不在本批改变时间/时区语义。
| 字段 | 类型 | 含义 |
|---|---|---|
| id | integer / int64 | 通知 ID,用于标记已读 |
| type | string | 通知事件类型,如 INTERVIEW_INVITED,不等于业务类型 |
| title | string | 通知标题 |
| content | string / null | 展示正文,历史内容可空;禁止从文案提取目标 ID |
| read | boolean | 当前已读状态;false 才显示该条红点 |
| readAt | string / null | 已读时间,未读时 null |
| createdAt | string | 创建时间 |
| businessType | string / null | 新增:持久化关联业务类型,开放字符串,不是封闭枚举 |
| businessId | integer / int64 / null | 新增:持久化业务记录 ID,不是通知 ID,也不一定是岗位 ID |
两项业务关联字段在本批返回中保留,历史无关联时明确为 null,不从文案猜测,不执行历史回填。部分旧数据也可能只有一个字段有值,前端须把不完整关联视为不可导航。
项目全局配置是 `non_null`。本批仅在通知条目视图局部保留 null,不改全局配置;因此历史条目会明确含 `businessType: null`、`businessId: null`,readAt 等通知条目中的 null 也会显式输出。新旧客户端均应接受这些附加键。
### 已读和未读数
| 操作 | 请求 | 成功 data |
|---|---|---|
| 单条已读 | `POST /notifications/{id}/read`,无请求体 | `{"message":"marked as read"}` |
| 全部已读 | `POST /notifications/read-all`,无请求体 | `{"marked":3}`(本次实际变更数量) |
| 未读数 | `GET /notifications/unread-count` | `{"unreadCount":3}` |
均返回统一响应 code=200。重复单条已读不重复计数且不重写原 readAt;重复全部已读通常 marked=0。新通知并发到达时,以随后获取的未读数为准,不能保证永久为 0。
现有错误语义保留:未登录或原 Token 对应账号已禁用返回 401;他人通知的已读操作返回 403 / 2002;通知不存在实际返回 **400 / 9105**,不是 404;分页非法返回 400 / 1001。企业账号仍受已有企业认证门禁约束,不在本批放宽。
## 2. 点击和已读接入流程
### 前端需要修改的位置
以下来自只读检查 `D:\Projects\mydreamjob`(HEAD `cbe7382`),仅作为定位线索;前端工程师须以自己的最新分支复核。后端本轮没有修改这些文件。
| 文件 | 当前观察 | 需要完成 |
|---|---|---|
| `src/types/message.ts` | Notification 只有展示字段,type 为 offer/system/promotion | 保留 UI 分类,新增原始事件类型和业务关联字段 |
| `src/api/api.ts` 的 mapBackendNotification | 映射时丢掉业务关联和原始事件类型 | 透传并校验新增字段,content=null 时安全展示 |
| `src/api/api.ts` 的 fetchNotifications | 只取第一页并返回数组,分页信封丢失 | 支持 page/size,保留 total/totalPages,不能只展示默认前 20 条却称为全部通知 |
| `app/(tabs)/chat.tsx` 的 NotificationCard | 使用 View,没有点击处理 | 增加可点击交互、通知全文展示、已读和业务导航 |
| `src/hooks/useMessageData.ts` | 独立聚合通知状态,子请求失败会降级为空数组 | 对接读状态同步;通知失败应呈现可重试状态,不能误报“暂无通知” |
| `app/notifications.tsx`、`src/hooks/useNotifications.ts` | 已有已读/全部已读入口,但状态局部独立 | 与沟通 Tab 同步,处理请求失败和业务跳转 |
| `src/hooks/useNotifications.ts` 的 useUnreadCount | 未读数独立获取 | 已读后刷新并通知所有入口,切账号清理旧结果 |
### 字段映射约定
保留现有 `type: 'offer' | 'system' | 'promotion'` 用于图标,不强行替换为后端大写事件类型,避免破坏现有图标映射。建议给 Notification 增加以下字段(命名可按项目规范,但语义必须保留):
```ts
// Notification 中的增量字段
eventType: string; // 原始 raw.type,例如 INTERVIEW_INVITED
businessType: string | null; // 原始 raw.businessType,不从文案推断
businessId: number | null; // 原始 raw.businessId
readAt: string | null;
createdAt: string;
```
- 通知 id 沿用现有 string 表示,例如 `String(raw.id)`;标记已读用通知 id,业务跳转用 businessId。
- 为兼容尚未发布的新后端,旧响应缺失关联字段时按不可导航处理,禁止从文案补造;发布后联调需确认两个关联键确实存在。
- 导航前检查 `typeof businessId === 'number' && Number.isSafeInteger(businessId) && businessId > 0`。不应对非法值、字符串或超出安全整数范围的数值直接拼路由;不要依靠 parseInt 静默修复脏数据。
- read 使用服务端布尔值;content 为 null 时展示空正文或“暂无详细内容”,不要显示字面量 null。
### 点击、已读与刷新
1. 列表映射须保留原始 type、businessType、businessId;不要只保留 title/content/read,也不要把所有 type 折叠为 system 后丢掉原类型。
2. 点击条目先展示通知全文,再按下表解析关联。只支持明确映射的类型,并校验 businessId 是可用正整数;应用内路由由前端按当前角色和现有页面实现。
3. 用户查看通知即可以通知 id 调用单条已读。通知阅读和关联详情能否访问是两回事:即使目标已删除,也仍可阅读原通知并标记已读。
4. 已读成功后更新列表项并刷新独立的未读总数;失败时保留未读或回滚乐观状态,提供重试,不能静默清除红点。切换账号后禁止旧请求结果写回新账号状态。
5. 关联业务详情仍使用既有面试/投递/Offer 接口并携带当前用户凭据。通知关联不是新的访问授权;禁止凭通知 ID 或 businessId 绕过权限。
6. 未知类型、空/不完整关联、目标已删除/无权限时,保留通知全文,并提示“关联内容不可用”;未登录应走登录恢复,不伪装为内容不存在。
7. “全部已读”由用户主动触发;成功后刷新条目和未读数,不建议仅进入列表就自动把所有页标成已读。
8. 页面回到前台、进入通知 Tab 或读状态变化后,按项目统一刷新机制同步。此批没有新增通知实时推送协议,不能承诺 WebSocket 即时更新。
9. 两个通知入口应复用当前账号的状态或共享失效通知机制,不允许只刷新当前卡片而其他页面继续保留旧红点。可以复用项目已有缓存/事件机制,不要求为此更换全局状态库。
10. 全局红点/数量使用 `/notifications/unread-count`,不能使用第一页的 `items.some(!read)` 代替;第一页全已读不代表后续页无未读。“全部已读”按钮也不能仅依据当前页判断是否可用。
11. 对同一条通知的连续点击去重,全部已读提交时防止重复点击;刷新请求需要账号隔离和旧响应保护。不要在请求失败或超时时直接把未读数硬设为 0。
### 业务目标与现有路由边界
| 关联类型 | 前端行为 |
|---|---|
| APPLICATION、MATCH | ID 均为投递 ID。当前求职者页为 `/my-application-detail?id=<businessId>`,企业页为 `/application-detail?id=<businessId>`;按当前角色选择,仍由详情接口校验权限 |
| OFFER | 当前已有 `/offers/[id]` 双端共用详情,使用 Offer ID;请复核最新实现 |
| INTERVIEW | 使用现有 `GET /interviews/{interviewId}` 读取企业面试详情,展示邀请/时间/最新状态;需要前端补真实业务详情页或详情弹层,并可复用面试列表中的数据与组件 |
| VERIFICATION、REPORT、未知类型、空/不完整关联 | 在没有确认可用且授权的业务页面前,只展示通知全文及不可用提示,不伪造路径 |
**特别注意:当前 `app/interview.tsx` 使用 useAIInterview,是 AI 面试功能,不是企业邀约的业务面试详情页。禁止把 INTERVIEW 通知直接跳到 `/interview` 当作完成。** 面试取消后应展示“已取消”等最新业务状态,不把通知当作仍然有效的邀请。
业务详情可能已删除、已失效或当前用户无权限。此时通知本身仍可阅读、标记已读;详情访问失败应有明确反馈。401 应走登录恢复,403/404 或具体业务不可用码应按该详情接口实际契约处理,不一律吞掉,也不要把业务字段当成额外访问授权。
## 3. 已核查的业务关联表
以下为源码生产者核查,不等于每种事件均已做真实端到端验证;具体测试范围见本批验证记录。
| 生产者 / 事件 | 接收者 | businessType / businessId |
|---|---|---|
| ApplicationService:APPLICATION_APPLIED、APPLICATION_WITHDRAWN、APPLICATION_STATUS | 投递求职者 | APPLICATION / 投递 ID;投递成功文案中的岗位 ID 不能用于此字段 |
| ApplicationAcceptanceService:APPLICATION_ACCEPTED | 投递求职者 | APPLICATION / 投递 ID |
| InterviewService:INTERVIEW_INVITED、INTERVIEW_RESCHEDULED、INTERVIEW_CANCELLED、INTERVIEW_COMPLETED | 面试求职者 | INTERVIEW / 面试 ID,不是投递 ID |
| InterviewService:INTERVIEW_CONFIRMED、INTERVIEW_DECLINED、INTERVIEW_RESCHEDULE_REQUESTED | 所属企业 owner 用户 | INTERVIEW / 面试 ID |
| InterviewScoreService:INTERVIEW_SCORE_SUBMITTED | 评分对应求职者 | INTERVIEW / 面试 ID,不是评分 ID |
| OfferService:OFFER_SENT、OFFER_REMINDER | 候选人 | OFFER / Offer ID |
| OfferService:OFFER_ACCEPTED、OFFER_REJECTED、OFFER_NEGOTIATING、OFFER_EXPIRED、OFFER_WITHDRAWN | 所属企业 owner 用户(按现有逻辑) | OFFER / Offer ID |
| OfferExpiryWorker:OFFER_REMINDER | 候选人 | OFFER / Offer ID |
| OfferExpiryWorker:OFFER_EXPIRED | 候选人及所属企业 owner 用户(两个独立事件) | OFFER / Offer ID |
| MatchNotificationService:MATCH_SUCCEEDED | 候选人及所属企业 owner 用户 | **MATCH / 投递 ID**,不是匹配记录 ID;前端可基于投递详情或现有投递匹配接口处理 |
| CompanyVerificationService:VERIFICATION_APPROVED、VERIFICATION_REJECTED | 提交认证的用户 | VERIFICATION / 企业认证申请 ID,不是 companyId |
| ReportService:REPORT_DECISION | 举报人 | REPORT / 举报单 ID,不是被举报对象 ID |
| 四参 NotificationService.create、历史/纯系统通知 | 调用方指定接收者 | 无明确关联时两个字段均 null,仅展示原文 |
通知消费者将事件中的 businessType/businessId 原样写入;本批不修改事件类型、接收者、发消息时机、幂等键或通知文案。未发现上述已查生产者需要补造关联字段,故未改这些生产业务代码。
## 4. 联调验收与限制
- 面试、投递、Offer、MATCH、未知类型以及历史无关联:字段正确,路由不靠文本推断。
- 点击一条未读 → 单条已读成功 → 本项红点及总未读数更新 → 重新进入仍已读。
- 重复点击及“全部已读”幂等;超过 100 条、相同创建时间的多页通知不遗漏。
- A/B 两账号隔离;禁用原 Token、他人通知、通知不存在均按上文错误规则处理。
- 关联详情目标无权限或已不存在时,通知仍能展示;没有返回内部 userId/idempotencyKey。
- 列表保留分页信封;此批不新增详情、路由、聚合接口,不新增迁移,不回填旧数据。
- 后端本地改动不会自动修复前端点击事件、红点状态或完成线上部署。完整构建、真实 MySQL、JAR 黑盒、前端点击及上线结果分别登记,不互相替代。
### 前端交付验收清单
1. **两个入口一致**:沟通 Tab、独立通知页均可点击;任一入口阅读后,另一入口和全局未读数同步。
2. **持久已读**:未读红点 → 点击 → 已读接口成功 → 红点消失 → 返回/重新登录后仍已读;重复点击不重复扣数。
3. **失败不伪成功**:模拟已读、全部已读、列表或未读数请求失败,显示重试;不会把失败当空列表或把未读数直接清零。
4. **分页正确**:至少 25 条通知、第二页仍有未读;第一页读完时全局提示仍正确,加载更多不重复、不遗漏;全部已读处理所有页。
5. **业务跳转**:投递成功/状态变更/企业接受进入正确投递;Offer 进入正确 Offer;面试邀请/改期/取消展示真实业务面试,不进入 AI 面试。
6. **降级正常**:历史 null、未知类型、单边字段缺失、目标已删除或无权限,仍可展示通知全文并反馈原因。
7. **账号隔离**:A 请求未完成时切 B,A 的列表、已读响应和未读数不能覆盖 B。
8. **不从文案猜 ID**:把标题/正文中的“#编号”改为无关值,跳转仍只由 businessType/businessId 决定。
前端交付请提供:修改文件列表、TypeScript/相关测试结果、实际联调地址与后端版本、关键请求参数与响应字段(去除 Token/验证码)、双入口与跨页操作录屏或截图。尚未拿到运行中的新后端时,仅能登记前端单测/模拟测试通过,不能登记真实接口联调通过。

64
docs/notification-trash-development-verification-20261028.md

@ -0,0 +1,64 @@
# 通知第二批开发验证记录:删除、多选与回收站
日期:2026-10-28。状态:**开发验证通过,尚非完整发布验收**。仅后端、测试和必要文档;未改前端,未提交、推送、触发 Jenkins 或发布,未修改线上数据库。
## 1. 实现范围
- 通知以 `deleted_at` 软删除;支持单条/批量移入回收站、单条/批量恢复和回收站分页。不存在永久删除、清空、自动过期清理或历史操作页面。
- 删除只影响当前用户的通知,不取消面试,不删除投递/Offer,也不影响对方收到的通知。
- 原正常列表不再展示软删除通知,条目仍保持第一批九个字段;回收站增加 `deletedAt`,按删除时间、ID 倒序。
- 原未读通知删除后不计数;恢复保留 read/readAt,原未读重新计数。全部已读只处理正常列表。
- 批量请求原始长度 1~100,ID 必须为 JSON 正整数,去重后返回 requested 和实际 changed 数量。任何越权或不存在 ID 导致整批失败,无部分成功。
- 单条及批量操作使用行锁,批量按 ID 升序加锁;单条已读也使用行锁,避免并发删除被陈旧实体覆盖而复活。全部已读条件更新只写 read/readAt,排除回收站。
- 原个人数据导出仍按原归属全量查询,软删除并不等于物理清除。账号注销清理范围不因本模块改变。
## 2. 文件范围
本批共 21 文件;相对第一批已验收的 13 文件,重叠 5 文件,合并后共 **29 文件**。准确路径和 SHA256 以证据目录清单为准。
| 文件组 | 用途 |
|---|---|
| Notification.java / NotificationRepository.java | 删除时间、正常/回收站分页、排除删除项的计数、行锁与条件已读更新 |
| NotificationService.java / NotificationController.java | 五个新接口、批量原子性和权限、删除/恢复幂等、保持原通知关联视图 |
| NotificationBatchRequest.java / NotificationBatchResult.java / NotificationChangeResult.java | 严格正整数数组和数量上限、去重数量/实际变化结果 |
| NotificationTrashResponseDoc.java | 回收站分页及 deletedAt 的运行时契约 |
| V64__notification_recycle_bin.sql | 新增可空 deleted_at 与用户/删除时间/ID 索引,不改旧迁移或回填历史数据 |
| NotificationServiceTest.java | 适配正常列表过滤、加锁已读、条件全部已读 |
| NotificationTrashIntegrationTest.java | JWT 过滤链下 HTTP 语义、参数、权限、幂等、分页、批量原子性与并发 |
| NotificationTrashMySqlIntegrationTest.java | 相同套件在真实 MySQL 上执行,可指定 8.4 或 5.7.36 |
| NotificationTrashMySqlMigrationTest.java | 双 MySQL V63→V64、旧数据与历史校验和、幂等及 validate |
| CompanyResumeDownloadIntegrationTest.java / UnifiedRegistrationIntegrationTest.java / ProfileImageIntegrationTest.java / WorkExperienceOptimizationSecurityIntegrationTest.java | 同步五个新认证操作带来的现有契约计数,未跳过或削弱权限断言 |
| docs/openapi-rc.json / docs/verify_openapi.py | 221 paths / 271 operations,新增结构、认证、批量约束及无永久删除约束 |
| 本记录 / notification-trash-frontend-handover-20261028.md | 验证边界及前端对接 |
## 3. 已执行及证据
证据目录:`D:\Acceptance\qsp-notification-trash-20261028-a1`。
| 验证 | 实际结果 |
|---|---|
| 最终 9 类定向测试 | **117 tests / 0 failures / 0 errors / 0 skipped**,Maven 退出码 0 |
| 真实 MySQL 8.4 通知套件 | **22/22**,完整认证过滤链 + MockMvc,不冒充实际端口 JAR 黑盒 |
| 真实 MySQL 5.7.36 通知套件 | **22/22**,同上;Hibernate 对 5.7 支持范围有警告,实际专项无失败 |
| V63→V64 真实双库迁移 | **2/2**:旧通知字段及 read/readAt 不变,deleted_at 初始 NULL,索引正确,历史 checksum 不变,二次迁移 0、validate 成功 |
| 静态与 Spring 测试运行时契约 | **221 paths / 271 operations**,路径方法集一致,五个新操作身份/鉴权及四个请求/响应模型一致 |
| 静态校验器反向变异 | **10/10 拒绝**:缺鉴权、错误 operationId、空数组、超限、字符串/零 ID、必填约束、缺删除时间及正常列表内部字段泄露 |
| git diff --check | 本批与合并通知文件范围检查,无空白错误 |
关键日志:`targeted-final.log`、`mysql84-run1.log`、`mysql57-run1.log`、`migration-contract-run1.log`。最终定向 XML 归档在 `final-test-xml/`,双库通知套件各自保存 `mysql84-tests.xml` / `mysql57-tests.xml`,不会以后一轮覆盖冒充两轮证据。
计数修正后实际通过:企业简历下载 19 项、统一身份注册 13 项、头像 18 项、AI 权限 9 项。禁止将本批定向通过当作这些模块的完整发布验收。
开发过程中静态契约补丁首次生成使用系统输出编码,导致新标签中文损坏;静态校验已拒绝该结果。改用 UTF-8 重新生成后通过,最终契约与运行时一致。没有通过修改全局序列化、关闭鉴权或跳过测试来消除失败。
## 4. 未执行及发布边界
以下均为 **NOT EXECUTED**:本批合并基线的完整 `mvn clean verify`、同次打包 JAR 的真实端口 HTTP 黑盒、仅重启 JAR 的持久化、前端左滑/长按/跳转实机联调、线上部署。
第一批 `D:\Acceptance\qsp-notification-full-20261028-a1` 的全量/JAR 结果仅覆盖第一批冻结源码。本次新增 V64 和五个接口,不能复用第一批 JAR 或将旧 PASS 视为合并基线也通过。
本批不操作已有业务容器;专项使用 Testcontainers 创建的临时隔离数据库,自动停止清理。没有删除已有数据卷或验收报告。旧迁移内容、原仓库 HEAD/暂存区及非本批起始文件哈希保持不变,详情见机器摘要。
交接文档:`docs/notification-trash-frontend-handover-20261028.md`,结构为“接口与字段 → 左滑/多选/回收站流程 → 权限与错误处理 → 联调验收”。没有需要新增授权才能完成的本批编码问题;若后续需要永久删除或自动保留期限,应另立需求,不能自行增加。
下一步:以 `combined-whitelist.json` 的 **29 文件合并基线**建立独立副本,完整构建成功后锁定同次 JAR,验证删除/恢复、权限、并发、契约和重启,再决定发布。当前工作区不能按第一批旧 13 文件清单直接发布。

125
docs/notification-trash-frontend-handover-20261028.md

@ -0,0 +1,125 @@
# 通知第二批:删除、多选与回收站对接
日期:2026-10-28。面向前端开发/联调。仅描述本批后端实现,**尚未提交、推送或发布**。第一批“业务关联与已读”交接文档继续适用;本批没有新增历史记录页面、永久删除或自动清空。
## 1. 接口与字段
前缀 `/api/v1`,全部接口使用正常 access token:`Authorization: Bearer <accessToken>`,身份从安全上下文取得。不得传 userId 来指定通知归属;企业仍受已有认证门禁限制。
| 操作 | 方法与路径 | 请求 | 成功 data |
|---|---|---|---|
| 正常列表(已有) | `GET /notifications?page=0&size=20` | page≥0,size=1~50 | 原分页信封,仅正常通知 |
| 回收站 | `GET /notifications/trash?page=0&size=20` | 同上 | 回收站分页信封 |
| 单条移入回收站 | `DELETE /notifications/{id}` | 无请求体,正整数通知 ID | `{"changed":true}` |
| 单条恢复 | `POST /notifications/{id}/restore` | 无请求体,正整数通知 ID | `{"changed":true}` |
| 批量移入回收站 | `POST /notifications/batch-delete` | `{"ids":[28,29]}` | `{"requested":2,"changed":2}` |
| 批量恢复 | `POST /notifications/batch-restore` | `{"ids":[28,29]}` | `{"requested":2,"changed":2}` |
均为统一响应 code=200,data 外仍有 message、requestId、timestamp。重复删除已删除通知、恢复正常通知均成功但 changed=false;批量 changed 是实际改变状态的条数,requested 是去重后的 ID 数。
### 批量约束
- ids 必须为非空数组,原始数组长度 1~100。超过 100 条即拒绝,不会先去重再绕过上限。
- 只接受 JSON 正整数,不能是字符串、浮点数、布尔值、null、0、负数或超出 int64 范围的值。
- 重复 ID 去重,例如 `[28,28]` → requested=1,最多改变一次。
- **整批原子操作**:任意 ID 不存在、归属其他账号,整批失败,无部分成功。已经处于目标状态的本人通知是幂等项,不构成失败。
- 前端现有通知 id 若存为 string,发送批量请求前必须严格校验并转为安全整数;不能把 string 数组直接发给后端,也不能用 parseInt 静默截断。
```ts
function batchIds(ids: string[]): number[] {
if (ids.length < 1 || ids.length > 100) throw new Error('一次请选择 1~100 条通知');
return [...new Set(ids.map(id => {
if (!/^[1-9]\d*$/.test(id)) throw new Error('通知 ID 无效');
const value = Number(id);
if (!Number.isSafeInteger(value)) throw new Error('通知 ID 超出安全整数范围');
return value;
}))];
}
```
### 回收站响应
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [{
"id": 28,
"type": "INTERVIEW_INVITED",
"title": "面试邀请",
"content": "你收到一条面试邀请",
"read": false,
"readAt": null,
"createdAt": "2026-10-27T10:00:00",
"businessType": "INTERVIEW",
"businessId": 123,
"deletedAt": "2026-10-28T10:00:00"
}],
"total": 1,
"page": 0,
"size": 20,
"totalPages": 1
}
}
```
- 正常列表仍为第一批九个条目字段,不额外返回 deletedAt;回收站在原字段上增加非空 deletedAt。
- 正常列表按原 createdAt/id 倒序;回收站按 deletedAt/id 倒序。恢复后保留原 createdAt,回到正常列表的原时间位置,不保证出现在第一条。
- 删除/恢复不改 type、正文、业务关联、read 或 readAt,也不创建新通知;重复删除不刷新 deletedAt。
## 2. 左滑、多选与回收站流程
### 正常通知列表
1. **左滑**:露出“删除”按钮,点击后才发送 DELETE。不要一次滑动就直接执行。
2. 成功后从正常列表移除该项,并刷新分页总数和全局未读数。失败保留或回滚该项,显示可重试反馈。
3. **长按**:进入多选模式,显示选择数、“取消”“删除所选”,可以提供“全选当前已加载项”。
4. 单次最多选择 100 条。已加载更多时应明确限制或提示分批处理,禁止静默截断,也不要后台拆成多批却声称整次操作原子成功。
5. 批量删除前确认所选数量。成功后退出或更新选择状态;失败不应清空选择,也不能只移除一部分记录。
6. 正常点击继续走第一批“通知全文/业务详情 + 标记已读”,与左滑、长按手势区分,避免误触。
### 回收站
1. 提供独立回收站入口和分页列表,支持单条恢复、长按多选及恢复所选。
2. 回收站不提供“再次删除”“永久删除”“清空回收站”,也不自动过期清理。
3. 回收站中可以查看通知内容,但不要因浏览而调用标记已读。恢复后再按正常通知流程阅读。
4. 恢复成功后刷新正常列表、回收站、两个通知入口和全局未读数;恢复失败保留条目并提示重试。
### 红点与已读规则
- 未读通知移入回收站后,不再计入 `/notifications/unread-count`。
- 恢复时保留原 read/readAt:原未读通知重新计数;原已读通知仍已读,不增加未读数。
- 原 `POST /notifications/read-all` **只处理正常列表**,不会修改回收站中的通知。
- 对回收站里的通知调用 `POST /notifications/{id}/read` 会拒绝,不得把它当作已读成功。
- 不要直接用 changed 扣减未读数:删除项可能混合已读与未读。成功后以未读数接口为准,并保护账号切换和旧响应覆盖。
## 3. 权限、错误与业务边界
| 场景 | 行为 |
|---|---|
| 未登录、过期或禁用账号原 Token | 401,走登录恢复 |
| 参数或 JSON ID 格式非法 | 400,通常 code=1001,修正请求后重试 |
| 通知不存在 | 沿用既有 HTTP 400 / code=9105 |
| 对回收站条目标记已读 | HTTP 400 / code=9105,提示先恢复,不假装已读 |
| 删除/恢复其他账号的通知 | 403 / code=2002;批量整批失败 |
| 未认证企业 | 沿用企业认证门禁,按具体错误码展示,不一律解释成通知归属问题 |
| 网络超时,是否成功未知 | 先刷新状态或安全重试;删除/恢复幂等,不能宣称已成功 |
删除仅是**当前用户自己的通知软删除**,不取消面试、不删除投递/Offer、不影响企业或对方收到的通知。业务详情的访问权限仍由各业务接口校验;业务目标已失效也不妨碍删除或恢复通知。
软删除不是物理清除数据;个人数据导出及账号注销清理继续遵循现有范围和规则。本模块不增加永久删除能力或历史操作记录页面。
## 4. 联调验收
- 未读删除 → 红点计数减少 → 回收站保留 read=false → 恢复后重新计数。
- 已读删除/恢复后 readAt 不变,未读数不增加;重复操作 changed=false 或 0。
- “全部已读”只影响正常列表;回收站原未读恢复后仍未读。
- 正常列表和回收站分别分页;相同删除时间下顺序稳定,无重复/漏项。
- 1 条、100 条、重复 ID、101 条、非法 ID 和空选择均正确处理。
- 批量混入他人或不存在 ID,整批失败,无部分消失或部分恢复。
- A/B 账号隔离、切账号时旧响应不回写;未认证企业、禁用账号不放宽。
- 并发点击已读与删除,不会让通知意外复活;并发批量删除幂等。
- 前端只提交明确选择的 ID,不进行永久删除、不取消关联业务。
后端定向/迁移测试、完整构建、同次 JAR 黑盒、前端实机手势和线上部署分别登记,不互相替代。第一批验收快照保存在 `D:\Acceptance\qsp-notification-full-20261028-a1\`;当前工作区已叠加第二批,**不能再用第一批旧 13 文件清单直接发布当前工作区**。后续须按第一批依赖 + 第二批改动的完整合并基线重新验收。

2
docs/offer-advance-api.md

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

2
docs/offer-api.md

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

434
docs/onboarding-api.md

@ -0,0 +1,434 @@
# 入职清单与公司知识介绍 API(N04)
> 状态:后端实现完成,待真实 MySQL 8.4 / Docker 黑盒验收。
>
> 数据库迁移:`V50__onboarding_checklist_and_company_knowledge.sql`、`V51__onboarding_standard_checklist_seed.sql`。
## 1. Base URL 与认证
本地 Base URL:
```text
http://127.0.0.1:8080/api/v1
```
所有 `/onboarding/**` 接口都要求登录,并携带:
```http
Authorization: Bearer <accessToken>
Content-Type: application/json
X-Request-Id: <optional-trace-id>
```
细粒度角色和所有权在 Service 层校验。除 401 外,接口统一返回 `ApiResponse`:
```json
{
"code": 200,
"message": "ok",
"data": {},
"requestId": "req-...",
"timestamp": "2026-09-17T01:30:00Z"
}
```
## 2. 接口与权限总览
| # | Method | Path | 角色 | 说明 |
|---|---|---|---|---|
| 1 | GET | `/onboarding/companies/{companyId}/checklist` | STUDENT | 读取有效清单、完成状态和完成率 |
| 2 | PUT | `/onboarding/companies/{companyId}/checklist/items/{itemId}/completion` | STUDENT | 设置本人完成或未完成 |
| 3 | GET | `/onboarding/companies/{companyId}/knowledge` | STUDENT | 读取企业资料、知识内容和公开评价摘要 |
| 4 | GET | `/onboarding/company/checklist-items` | COMPANY | 查询本企业活动与软删除自定义项 |
| 5 | POST | `/onboarding/company/checklist-items` | COMPANY | 创建本企业自定义项 |
| 6 | PUT | `/onboarding/company/checklist-items/{itemId}` | COMPANY | 按版本更新本企业条目 |
| 7 | DELETE | `/onboarding/company/checklist-items/{itemId}?version={version}` | COMPANY | 按版本软删除本企业条目 |
| 8 | GET | `/onboarding/company/knowledge` | COMPANY | 查询本企业知识配置 |
| 9 | PUT | `/onboarding/company/knowledge` | COMPANY | 创建或更新本企业知识配置 |
权限规则:
- STUDENT 接口只允许拥有 STUDENT 角色的账号访问。
- COMPANY 接口只允许拥有 COMPANY 角色且 `companies.verified=true` 的账号访问;未认证企业统一返回 `403/5004`,在读取或写入本企业清单、知识内容前拒绝。
- COMPANY 接口不接受 `companyId`;服务端从 JWT 用户解析其所属企业,避免跨企业写入。
- ADMIN 没有 onboarding 专属管理接口;调用 STUDENT/COMPANY 接口时按非目标角色返回 `403/2002`。
- 学生访问企业清单或知识不要求先有 Offer 或投递记录,但目标企业必须存在。
- 企业条目不存在或属于其他企业时统一返回 `404/10501`,不泄露其他企业数据。
## 3. 入职清单语义
### 3.1 通用项与自定义项合并
有效清单由以下两部分组成:
1. 所有活动通用项 `source=STANDARD`;V51 固定写入 12 条。
2. 当前企业所有活动自定义项 `source=COMPANY`。
合并结果按 `sortOrder ASC, id ASC` 稳定排序。企业没有活动自定义项时,`customized=false`,仍返回全部通用项。企业存在活动自定义项时,`customized=true`,返回通用项与企业项。
`category` 固定为:
```text
DOCUMENT | ADMIN | EQUIPMENT | TRAINING | LOGISTICS | OTHER
证件材料 | 行政手续 | 办公设备 | 培训学习 | 到岗安排 | 其他
```
### 3.2 学生读取有效清单
```http
GET /api/v1/onboarding/companies/10/checklist
Authorization: Bearer <studentAccessToken>
```
响应:
```json
{
"code": 200,
"message": "ok",
"data": {
"companyId": 10,
"companyName": "示例科技",
"customized": true,
"totalItems": 13,
"completedItems": 1,
"completionRate": 7.7,
"items": [
{
"itemId": 101,
"source": "STANDARD",
"itemKey": "standard-id-card",
"title": "准备身份证件",
"description": "准备身份证原件或有效身份证明,用于入职登记。",
"category": "DOCUMENT",
"required": true,
"sortOrder": 100,
"active": true,
"completed": true,
"completedAt": "2026-09-17T09:00:00",
"progressVersion": 1
},
{
"itemId": 501,
"source": "COMPANY",
"itemKey": "company-10-8f1c",
"title": "领取门禁卡",
"description": "到行政前台领取门禁卡并完成权限激活。",
"category": "ADMIN",
"required": false,
"sortOrder": 1000,
"active": true,
"completed": false,
"completedAt": null,
"progressVersion": 0
}
]
},
"requestId": "req-onboarding-checklist",
"timestamp": "2026-09-17T01:30:00Z"
}
```
`completionRate = round(completedItems * 100 / totalItems, 1)`;总数为 0 时返回 `0.0`。完成状态只属于当前 STUDENT。
### 3.3 更新完成状态
请求使用绝对状态,而不是切换:
```http
PUT /api/v1/onboarding/companies/10/checklist/items/501/completion
Authorization: Bearer <studentAccessToken>
{"completed": true, "version": 0}
```
响应:
```json
{
"code": 200,
"message": "ok",
"data": {
"itemId": 501,
"completed": true,
"completedAt": "2026-09-17T01:31:00Z",
"progressVersion": 1,
"totalItems": 13,
"completedItems": 2,
"completionRate": 15.4
},
"requestId": "req-onboarding-progress",
"timestamp": "2026-09-17T01:31:00Z"
}
```
版本规则:
- 首次保存某条目时可省略 `version` 或传 `0`。
- 已完成状态变化时必须传当前 `progressVersion`;过期返回 `409/10505`。
- 重复提交相同 `completed` 值保持幂等,不新增记录、不递增版本。
- 首次提交 `completed=false` 不创建记录。
- `completed=false` 会清除 `completedAt`。
- 一名学生与一个清单项最多一条进度记录。
### 3.4 企业创建自定义项
```http
POST /api/v1/onboarding/company/checklist-items
Authorization: Bearer <companyAccessToken>
{
"title": "领取门禁卡",
"description": "到行政前台领取门禁卡并完成权限激活。",
"category": "ADMIN",
"sortOrder": 1000,
"required": false,
"idempotencyKey": "company-10-access-card-20260917"
}
```
响应 `data`:
```json
{
"itemId": 501,
"source": "COMPANY",
"itemKey": "company-10-8f1c",
"title": "领取门禁卡",
"description": "到行政前台领取门禁卡并完成权限激活。",
"category": "ADMIN",
"required": false,
"sortOrder": 1000,
"active": true,
"version": 0
}
```
约束:
- `title`:必填,最多 120 字符。
- `description`:可选,最多 1000 字符。
- `category`:必填,仅允许固定枚举。
- `sortOrder`:可选,默认 `1000`,范围 `0..9999`。
- `required`:可选,默认 `false`。
- `idempotencyKey`:可选,最多 64 字符;服务端会 trim,空串按未提供处理。
- 活动自定义项上限为 50 条;达到上限返回 `409/10503`。
幂等重试:
- 同一企业、同一 `idempotencyKey`、同标题/说明/分类/排序/必填值:返回首次创建的条目。
- 同一企业、同一 key、请求语义不同:返回 `409/10504`。
- 不同企业可使用相同 key,互不冲突。
- 未传 key 时,每次请求创建新条目。
### 3.5 企业查询、更新与软删除
查询本企业全部条目:
```http
GET /api/v1/onboarding/company/checklist-items
Authorization: Bearer <companyAccessToken>
```
响应包含活动与软删除条目,不返回通用项和其他企业项。更新请求:
```http
PUT /api/v1/onboarding/company/checklist-items/501
Authorization: Bearer <companyAccessToken>
{
"title": "领取门禁卡并激活",
"description": "到行政前台领取门禁卡并完成权限激活。",
"category": "ADMIN",
"sortOrder": 900,
"required": true,
"active": true,
"version": 0
}
```
更新使用 `version` 乐观锁,成功递增版本;过期返回 `409/10502`。软删除:
```http
DELETE /api/v1/onboarding/company/checklist-items/501?version=1
Authorization: Bearer <companyAccessToken>
```
首次删除必须使用当前版本,系统只将 `active=false`,不物理删除。重复删除已非活动条目保持幂等成功;接口仍要求携带 `version` 查询参数。
## 4. 公司知识介绍语义
### 4.1 学生读取与回退
```http
GET /api/v1/onboarding/companies/10/knowledge
Authorization: Bearer <studentAccessToken>
```
响应:
```json
{
"code": 200,
"message": "ok",
"data": {
"company": {
"id": 10,
"name": "示例科技",
"logoUrl": "https://cdn.example.com/company-10.png",
"description": "面向企业提供数据基础设施产品。",
"industry": "互联网",
"website": "https://example.com",
"address": "杭州市余杭区",
"verified": true
},
"knowledgeState": "CONFIGURED",
"knowledge": {
"culture": "以用户价值为中心,保持透明和长期主义。",
"workPolicies": "弹性上班;每周一、三线下办公。",
"onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。",
"updatedAt": "2026-09-17T01:33:00Z"
},
"reviewSummary": {
"state": "SUFFICIENT",
"totalReviews": 4,
"averageRating": 4.5,
"ratingDistribution": {
"rating1": 0,
"rating2": 0,
"rating3": 1,
"rating4": 1,
"rating5": 2
},
"topTags": ["发展空间", "同事友好"],
"dimensionSummary": {
"AUTHENTICITY": {"averageScore": 4.5, "sampleCount": 4, "dataState": "SUFFICIENT"},
"BENEFITS": {"averageScore": 4.3, "sampleCount": 4, "dataState": "SUFFICIENT"},
"SALARY_TRANSPARENCY": {"averageScore": 4.0, "sampleCount": 4, "dataState": "SUFFICIENT"},
"JD_CLARITY": {"averageScore": 4.6, "sampleCount": 4, "dataState": "SUFFICIENT"},
"GROWTH": {"averageScore": 4.4, "sampleCount": 4, "dataState": "SUFFICIENT"},
"TEAM_ATMOSPHERE": {"averageScore": 4.7, "sampleCount": 4, "dataState": "SUFFICIENT"},
"PROMOTION": {"averageScore": 4.1, "sampleCount": 4, "dataState": "SUFFICIENT"},
"WORK_LIFE_BALANCE": {"averageScore": 4.0, "sampleCount": 4, "dataState": "SUFFICIENT"}
}
}
},
"requestId": "req-company-overview",
"timestamp": "2026-09-17T01:34:00Z"
}
```
回退与状态:
- 企业无知识记录:仍返回企业基础资料,`knowledgeState=BASIC_ONLY`,`knowledge` 省略。
- 企业有知识记录:`knowledgeState=CONFIGURED`,返回非空知识字段和 `updatedAt`;学生公开读取响应不返回知识配置版本。
- 企业不存在:`404/5001`。
- 响应白名单不包含 `ownerUserId`、用户 ID、手机号、邮箱、评价哈希或审核原因。
评价摘要只聚合企业全部岗位中 `PUBLISHED` 且审核状态为 `AUTO_APPROVED/APPROVED` 的评价:
| `reviewSummary.state` | 条件 | 返回内容 |
|---|---|---|
| `EMPTY` | 0 条 | `state`、`totalReviews=0` |
| `INSUFFICIENT` | 1–2 条 | `state`、真实 `totalReviews`;不返回均值、分布或维度汇总 |
| `SUFFICIENT` | 至少 3 条 | 平均评分、评分分布、热门标签、八维汇总 |
热门标签最多 10 个;八个维度分别返回 `sampleCount` 和 `dataState`。维度的 `dataState` 使用同一 3 条阈值。
### 4.2 企业查询知识配置
```http
GET /api/v1/onboarding/company/knowledge
Authorization: Bearer <companyAccessToken>
```
未配置时:
```json
{
"code": 200,
"message": "ok",
"data": {
"configured": false,
"version": 0
},
"requestId": "req-company-knowledge",
"timestamp": "2026-09-17T01:33:00Z"
}
```
已配置时返回 `configured=true`、非空内容、`updatedAt` 和当前 `version`。空字段按 `@JsonInclude(NON_NULL)` 省略。
### 4.3 企业创建或更新知识配置
```http
PUT /api/v1/onboarding/company/knowledge
Authorization: Bearer <companyAccessToken>
{
"culture": "以用户价值为中心,保持透明和长期主义。",
"workPolicies": "弹性上班;每周一、三线下办公。",
"onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。",
"version": 1
}
```
规则:
- 至少一个内容字段必须非空。
- `culture` 最多 2000 字符,`workPolicies` 和 `onboardingGuide` 各最多 4000 字符。
- 字段按纯文本处理,会 trim;包含 HTML 标签时返回 `400/1001`。
- 首次创建可以省略 `version`,创建后版本为 `0`。
- 更新已有记录必须提交当前版本;版本过期返回 `409/10506`。
- 已有内容与提交内容相同且未传版本时按幂等读取处理;内容不同但未传版本返回 `400/1001`。
## 5. 错误码
| HTTP | code | 含义 | 前端处理 |
|---|---:|---|---|
| 400 | 1001 | 参数、枚举、长度、HTML、缺少必填字段 | 展示字段错误,不自动重试 |
| 401 | 401 | 未登录或 Token 无效 | 清理登录态并重新登录 |
| 403 | 2002 | 角色不匹配、无所属企业或无权限 | 不重试,切换到正确角色 |
| 404 | 5001 | 企业不存在或 COMPANY 无绑定企业 | 检查 `companyId` 或企业绑定状态 |
| 404 | 10501 | 清单项不存在、跨企业或不属于有效清单 | 刷新清单后重新选择条目 |
| 409 | 10502 | 企业清单项版本过期 | 重新 `GET /company/checklist-items`,使用新版本重试 |
| 409 | 10503 | 活动自定义项达到 50 条上限 | 禁用新增,提示先软删除或复用现有项 |
| 409 | 10504 | 幂等键已用于不同请求体 | 更换幂等键,或确认原请求后读取原条目 |
| 409 | 10505 | 学生完成状态版本过期 | 重新读取有效清单并使用新 `progressVersion` |
| 409 | 10506 | 公司知识版本过期 | 重新读取企业知识配置并使用新 `version` |
401 由安全过滤器返回裸 JSON,没有统一 `ApiResponse.requestId`:
```json
{"code": 401, "message": "not authenticated", "timestamp": ""}
```
其他错误示例:
```json
{
"code": 10504,
"message": "idempotency key conflict: request body differs",
"data": null,
"requestId": "req-10504",
"timestamp": "2026-09-17T01:30:00Z"
}
```
## 6. 前端推荐流程
学生端:
1. 登录后调用 `GET /onboarding/companies/{companyId}/checklist`。
2. 使用响应的 `itemId` 和 `progressVersion` 调用完成状态接口。
3. 收到 `10505` 时重新读取清单,避免覆盖其他设备的新状态。
4. 调用 `GET /onboarding/companies/{companyId}/knowledge` 展示企业介绍与评价摘要。
5. 当 `reviewSummary.state=EMPTY/INSUFFICIENT` 时,不展示平均值、分布和维度均值。
企业端:
1. 登录后调用 `GET /onboarding/company/checklist-items` 获取当前版本。
2. 新增时生成稳定 `idempotencyKey` 并保存到重试请求中。
3. 更新或删除前使用最新 `version`,冲突后重新拉取列表。
4. 调用 `GET /onboarding/company/knowledge` 获取配置版本,再使用 `PUT` 保存。
5. 收到 `10503` 时停止自动新增;收到 `10504` 时不要复用旧请求体。

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

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

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

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

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

90
docs/profile-images-development-verification-20261027.md

@ -0,0 +1,90 @@
# 个人头像/企业 Logo:开发验证记录
日期:2026-10-27。范围:后端开发与定向回归,不是发布验收报告。
## 源码与边界
仓库:C:/Users/15642/Desktop/zhilai,HEAD 为 c6ccab54044c7df580a80cb140391c4d2a69c340。本轮开始时已有注销、评分、配置与文档等未提交修改;不把这些已有修改登记为本轮成果,也没有清理或覆盖它们。
新增 V63,不修改 V1~V62 历史迁移;无新增依赖。图片存数据库,不增加宿主机目录或容器挂载;不修改 MySQL 的既有 32 MiB 配置。未改前端、未提交、未推送、未触发 Jenkins、未发布。
## 已完成
- 上传自己的个人头像、上传自己的企业 Logo、分别删除和二进制读取。
- JPEG/PNG 扩展名/MIME/真实格式校验,2 MiB、4096 单边、400 万像素上限,最长边 512 重编码 JPEG,去除原始元数据。
- 个人头像与公司 Logo 独立;/auth/me 与现有会话摘要读取用户/公司当前引用。
- 私有头像只允许本人或符合当前 DIRECT 沟通授权的成员读取;第三方、拉黑、禁用、未选择身份、邀约隐私均有回归。
- 替换后新 URL、旧 URL 404、no-store;事务回滚恢复旧图,用户锁串行化并发替换。
- 企业普通资料更新也锁定企业行,避免旧快照覆盖并发 Logo 更新。
- 账号注销完成时清除二进制,物理删除时外键级联清理。
- 静态契约新增 3 paths / 5 operations,总量 216/266;同步受影响计数与身份门禁测试。
## 定向测试
以 Surefire XML/txt 为准;下表是不同测试类的汇总,重复执行不累计:
| 测试类 | tests | failures/errors/skipped |
| --- | ---: | --- |
| ProfileImageIntegrationTest | 18 | 0/0/0 |
| CompanyServiceTest | 11 | 0/0/0 |
| CompanyControllerMvcTest | 1 | 0/0/0 |
| CompanySizeDisplayIntegrationTest | 8 | 0/0/0 |
| CompanyResumeDownloadIntegrationTest | 19 | 0/0/0 |
| UnifiedRegistrationIntegrationTest | 13 | 0/0/0 |
| WorkExperienceOptimizationSecurityIntegrationTest | 9 | 0/0/0 |
| AccountDeletionPersistenceIntegrationTest | 9 | 0/0/0 |
| IdentityPolicyTest | 27 | 0/0/0 |
| 合计 | 115 | 0/0/0 |
ProfileImageIntegrationTest 校验整份运行时路径/方法集与静态契约一致,额外校验本批五个 operationId。静态 python docs/verify_openapi.py 与 git diff --check 均通过。
开发阶段第一次编译发现本批 SecurityRequirement 注解缺少必填 name,已修正;当时未执行测试,不把该次结果计入通过。首次静态 verifier 还发现 /auth 路径精确白名单缺少新头像路径,已同步并补专项断言。修正后重新运行,保留 target 下原始开发日志。
原始输出:
- mydreamjob-server/target/profile-image-test.log:首次编译失败。
- mydreamjob-server/target/profile-image-test-run2.log:初轮头像/企业服务回归。
- mydreamjob-server/target/profile-image-regression.log:八类扩大回归,88 项。
- mydreamjob-server/target/profile-image-final.log:头像与身份策略复跑,45 项;与上表有重叠,不另累计。
- mydreamjob-server/target/profile-image-contract-final.log:加强五个 operationId 比对后的头像复跑。
- mydreamjob-server/target/surefire-reports/:各类 XML/txt;后续 Maven 执行可能更新,独立验收时应自行归档。
## 尚未执行
- ProfileImageMySqlMigrationTest 的 MySQL 8.4 / 5.7.36 两个 V62→V63 专项:Docker 引擎当前不可连接(dockerDesktopLinuxEngine 管道不存在)。测试代码已增加,未执行,不计入通过。
- 完整 clean verify。
- 同次构建 JAR HTTP 黑盒、重启持久化。
- 前端图片组件、刷新/缓存策略、双设备真机联调。
H2 集成测试运行了 V63,但不能替代双真实 MySQL。当前结论仅为“后端实现及定向回归完成”,不能登记为完整验收通过或已上线。
## 本轮精确文件范围
新增:
1. mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImage.java
2. mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageRepository.java
3. mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageValidator.java
4. mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageService.java
5. mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageController.java
6. mydreamjob-server/src/main/resources/db/migration/V63__profile_images.sql
7. mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageIntegrationTest.java
8. mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageMySqlMigrationTest.java
9. docs/profile-images-frontend-handover-20261027.md
10. docs/profile-images-development-verification-20261027.md
修改:
11. mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyRepository.java
12. mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java
13. mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
14. mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java
15. mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java
16. docs/openapi-rc.json
17. docs/verify_openapi.py
18. mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java
19. mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
20. mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java
21. mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java
此清单是本轮开发范围,不自动授权提交或发布。独立验收应锁定这些文件的哈希,同时保留原工作区既有改动。

100
docs/profile-images-frontend-handover-20261027.md

@ -0,0 +1,100 @@
# 个人头像与公司 Logo:前端对接
日期:2026-10-27。本文面向前端开发与联调人员;本批只改后端和契约,不表示已部署或前端已经接入。
## 1. 展示规则
| 场景 | 数据来源 | 无图片时 |
| --- | --- | --- |
| 本人的个人资料/本人聊天气泡 | GET /auth/me 的 avatarUrl | 个人昵称首字 |
| 聊天对方为求职者 | 会话列表/详情的 avatarUrl | contactName 首字 |
| 聊天对方为企业 | 会话列表/详情的 avatarUrl,来源为公司 logoUrl | 公司名称首字 |
| 企业主页、岗位关联公司卡片 | 企业资料的 logoUrl | 公司名称首字 |
个人头像属于用户,Logo 属于企业,两个资源独立。企业账号修改个人头像不改变公司 Logo;修改 Logo 不改变 HR 本人头像。不要根据 URL 是否为空自行把个人头像替换成企业 Logo,或反过来。
本人的头像以 /auth/me 为统一来源,本批不改变 GET /profile/me 的空资料返回 {} 约定。会话摘要实时查询用户/公司引用,不从历史消息中固化头像。TALENT_INVITATION 会话继续隐藏候选人个人头像,不因新上传接口而放开隐私。
## 2. 上传、删除与读取接口
以下路径相对于 API 基址 /api/v1。所有写请求使用当前账号 access token,身份来自服务端,不接受客户端 userId/companyId 或文件路径。
| 方法 | 路径 | 权限/作用 |
| --- | --- | --- |
| POST | /auth/me/avatar | 已选择身份且有效的 STUDENT/COMPANY/ADMIN;上传自己的个人头像 |
| DELETE | /auth/me/avatar | 同上;清空个人头像,重复删除成功 |
| POST | /companies/me/logo | COMPANY 且已有自己的企业资料;上传企业 Logo |
| DELETE | /companies/me/logo | 同上;清空企业 Logo,重复删除成功 |
| GET | /profile-images/{imageId} | 公司 Logo 可匿名读;个人头像须鉴权且仅本人或具备当前 DIRECT 沟通授权的成员可读 |
企业不必先通过认证才能维护自己的 Logo,但上传 Logo 不改变企业认证状态,也不放开岗位发布门禁。
### 上传请求
multipart/form-data,字段名 file。使用 FormData,不要手动设置 Content-Type(让运行时自动带 boundary),不要把本机 file:// URI 或预设素材 key 作为远程图片 URL 提交。
允许 JPEG/PNG(扩展名、声明 MIME 与真实解码格式必须匹配),原文件最多 2 MiB;单边最多 4096、总像素最多 400 万。SVG/GIF/WebP/HEIC 等本批不支持。相册素材不满足格式时应在客户端转换并裁剪,或给出明确提示。
后端去除原图元数据及附加内容,等比例缩小到最长边 512,透明背景转白色,统一保存 JPEG,最大 512 KiB。文件名和原图不保存。
成功返回 envelope,data 示例:
{
"url": "/api/v1/profile-images/00000000-0000-0000-0000-000000000000",
"version": "00000000-0000-0000-0000-000000000000",
"contentType": "image/jpeg",
"size": 12345
}
示例 UUID 仅示意;每次上传生成新 UUID 和新 URL。个人上传同时更新 users.avatar_url;Logo 上传更新 companies.logo_url。替换/删除在数据库事务内完成,失败保留旧图与旧引用;不推进在线简历 revision。
删除成功 data 为 {"deleted":true}。重新取资料时没有头像/Logo 应使用占位图,不能继续展示旧本地缓存。
### 图片读取
url 是包含 API context path 的同源相对路径,不是完整域名。用 API 服务 origin 解析,不要再次拼 /api/v1,也不要让已登录下载器向任意外部 URL 附带 Bearer。
个人头像 GET 必须带 Authorization: Bearer access-token;普通图片组件不会必然继承业务请求层的鉴权,要给同源 source 配置认证请求头,或通过受控下载层取字节。禁止把 Token 拼进 URL。
返回 image/jpeg,Cache-Control: no-store,X-Content-Type-Options: nosniff。这是图片字节,不是 JSON,不能直接使用只解 JSON envelope 的 request() 当作图片下载器。
旧 URL 在替换/删除后返回 404。第三方登录账号即使知道私有头像 URL,也不能读取;具备 DIRECT 成员关系还必须满足当前拉黑、撤回及企业接收投递等沟通权限。公司 Logo 是公开企业展示素材,不能用它上传私密资料。
### 错误状态
- 400:文件缺失、空文件、格式/MIME/内容不一致、大小或像素超限。
- 401:缺少/无效/过期/禁用的登录凭证;匿名读个人头像。
- 403:身份不允许、非授权聊天成员、已失去沟通权限。
- 404:企业资料不存在、图片不存在/已替换/已删除、图片所属账号停用。
- 超出全局 multipart/网关大小上限可能更早被拒绝;前端仍应先限制 2 MiB。
不要向用户暴露文件系统路径、SQL、Token 或完整异常日志。
## 3. 缓存与状态同步
1. 上传成功立即把返回的新 URL 更新到本人资料状态;更新会话页共享状态或使会话列表/详情缓存失效。
2. 个人资料页与聊天页复用图片头像组件。先尝试图片 URL,失败/缺失才回退姓名首字;不能把 URL 渲染到 Text。
3. 页面重新聚焦时拉取当前 /auth/me、会话列表/详情,避免长期使用路由传参或首次进入时的旧快照。
4. 对方设备不会因为本机 setState 自动更新。本批没有新增头像变更 WebSocket 事件;对方通过列表/详情刷新获取新 URL。若要求停留聊天页时即时更新,需要前端刷新策略或另立头像事件协议。
5. 旧图收到 404 时只做受控的元信息刷新,不能无限重试旧 URL。新 URL 成功后覆盖显示,失败时使用占位头像。
6. 切换账号时清理身份相关状态及私有图片缓存;上一账号迟到的响应不能覆盖新账号。
本机历史 file://、本地预设头像不自动迁移到后端。需要前端获得可上传的实际文件并调用上传接口;只在个人资料页选中素材不算服务器保存成功。
## 4. 联调验收
- 求职者改头像后:个人资料、本人气泡、对方会话列表/详情使用同一新图。
- 企业改个人头像不改公司 Logo;改 Logo 后企业主页、岗位公司卡片和对方会话摘要使用新 Logo。
- 换设备/重新登录仍能从服务器读取;成功上传前不提示“已同步”。
- 连续替换、删除、并发上传均只保留当前版本;旧 URL 不再可读。
- 匿名/第三方账号不可读私有头像;合法 DIRECT 成员可读,拉黑后旧 URL 也不可读;邀约隐私保持。
- 测试空文件、假图片、MIME 伪装、超限、旧地址404与加载失败占位。
- 全流程不改变在线简历内容和 revision,不混用公司 Logo 和个人头像。
## 5. 存储与验收边界
新增 V63 迁移,仅创建 profile_images 表,不重写旧迁移,不搬运历史外链。每个 owner/kind 最多一张当前图片,二进制和引用在同一数据库事务内更新;数据库备份需包含该表。账号注销完成时清除其上传图片,物理删除用户/企业时外键级联清理。
现有企业资料中的历史 logoUrl 外链保持兼容;后端不会下载/代理任意外部图片。历史资料接口另写 logoUrl 后,已不再引用的新上传地址不可读;推荐统一使用本批上传/删除接口维护图片。
本轮定向验证结果见同目录 profile-images-development-verification-20261027.md。全量 clean verify、双真实 MySQL、同次 JAR 黑盒与重启,以及前端真机验收须按各自实际结果登记,不能以此文档替代通过证据。

19
docs/resume-api.md

@ -1,6 +1,9 @@
# 简历版本管理与导出模块接口说明 # 简历版本管理与导出模块接口说明
> 职来 (mydreamjob) · 简历版本管理与导出基础模块 > 2026-10-26 A7 新候选:真实模式改为 mode=deepseek + real-enabled=true 显式开启,不再要求 dev;新增 Redis 调用额度与并发门禁(429/Retry-After=60、Redis 故障503)。告知升级 work-experience-external-processing-v2,developmentOnly=false。下文旧版本/仅开发环境及旧验收记录属于历史,当前规则和上线边界以 [正式环境配置说明](ai-work-experience-production-config-20261026.md) 为准;本批尚未部署,不代表合规审核完成。
> 轻松聘 (mydreamjob) · 简历版本管理与导出基础模块
> 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>` > 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>`
## 1. 概述 ## 1. 概述
@ -254,3 +257,17 @@ GET /resume-export-tasks/{taskId}/download
- 学生本人的上传、下载、版本与导出接口语义完全不变。 - 学生本人的上传、下载、版本与导出接口语义完全不变。
- 企业候选人列表/详情 DTO **仍不含** `resumeUrl`。 - 企业候选人列表/详情 DTO **仍不含** `resumeUrl`。
- 不新增按 `studentUserId` 或 `candidateKey` 下载简历的入口;人才发现本轮不得直接下载简历。 - 不新增按 `studentUserId` 或 `candidateKey` 下载简历的入口;人才发现本轮不得直接下载简历。
## 工作经历 AI 优化(AI-001,本地整合候选,2026-10-23)
2026-10-26 告知候选更新:新增学生只读 `GET /profile/me/work-experience-optimization/notice`,优化 POST 必须带原生布尔 true 的 `externalProcessingConsent` 和当前 `consentNoticeVersion`。缺失/旧版本或字符串 true 拒绝;旧调用需同步接入。契约现为 213 paths / 261 operations;下文旧计数和缺版本示例为历史。详细流程及应用日志保护边界见 [告知与本次同意对接说明](ai-work-experience-consent-frontend-20261026.md)。本批未部署,仍默认 off、仅开发合成资料,不构成生产合规放行。
`POST /api/v1/profile/me/work-experience-optimization`,复用现有 Access Token,仅学生本人调用;返回建议,不自动保存、不修改评分或经历。用户采用建议后仍走现有带 revision 的保存接口。
模式 `AI_WORK_EXPERIENCE_MODE=off|stub|deepseek`,默认 `off`,兼容旧 `ai.work-experience.stub-enabled=true`;显式模式优先。`stub` 返回原文/补充问题,`simulated=true`。`deepseek` 仅允许 `dev` profile 和安全注入 Key 的合成数据联调,`simulated=false`,不允许生产直接开启。
当前合并契约为 212 paths / 260 operations,未回退原有简历、地图、企业接受沟通或附件接口。模型错误为安全固定文案;生产限流、隐私政策/正式告知授权及至少 30 例评测仍未完成。来源 `qingsongpin-AIDevelop@4784fd5` 的 22 项离线结果不替代本次目标工程验证。详细请求与 UI 流程见 `AI-001-工作经历AI优化-前端对接方案.md`。
后续本地修正候选使用 `work-experience-deepseek-v2`,保留职责否定范围、参与/指导程度、权限和上线限制;stub 版本不变。v1 质量批次第 29 例发生“不负责”→“不参与”的语义偏差后停止,未完成 30 例质量通过。v2 离线回归仅验证资源装载、规则/原文传递和程序行为,真实模型修正效果仍须单独授权复验;不据此开放生产。
2026-10-26 补记:A3 v2 真实复验实际 16 次,15 例质量通过,Q09 无经历输入被判为 OPTIMIZED 后停止,14 例未执行。新候选加入有限的整段/全分句不足信息判定;明确无事实输入本地返回 NEED_MORE_INFO、空描述和补充问题,不外呼;同类无事实模型输出也归一处理。短真实经历和混合具体事实不得按长度或“暂无”等包含词误判,stub 移除原少于 10 字阈值。角色、同意、敏感校验、默认关闭及无保存行为不变;API/DTO/提示词版本不变,由新基线和 JAR 身份区分。simulated=false 是模式标记而非付费调用凭证,本地处理不计入模型质量案例。新候选验证结果以新报告为准,不视为生产批准。

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

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

2
docs/review-moderation-api.md

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

198
docs/salary-calculator-api.md

@ -0,0 +1,198 @@
# 薪资计算器 API(N05)
> Base URL:`/api/v1`
> 认证:`Authorization: Bearer <access-token>`
> 角色:当前登录用户必须具备 `STUDENT` 角色。
> 发布状态(2026-10-08):政策核验白名单默认为空;种子数据已入库不代表已获准发布。默认不提供城市参数或新计算,本人历史查询/删除仍保持原有角色及归属限制。
## 1. 能力边界
- 输入税前月薪,估算个人五险一金、大病固定费和当月个税后的到手工资。
- 采用居民个人综合所得累计预扣法;`monthIndex` 表示当前工资口径下的累计计税月份数,而不是自然月编号。
- 支持用户覆盖社保基数、公积金基数和个人比例。
- 城市政策以地级行政区、直辖市和省直辖县级市为最小单元,不拆分到区县。
- `dataLevel=CITY` 表示城市级参数;`dataLevel=PROVINCE_FALLBACK` 表示由于公开城市级资料不足而使用省级回退参数,结果精度较低。
- 所有结果均为估算值,实际以工资单、当地政策和税务申报为准。
## 2. 查询城市列表
`GET /salary-calculator/cities`
仅返回启用且“城市编码 + 当前政策版本”均命中发布白名单的摘要,按省份编码和城市编码排序。默认返回 `200`,`data=[]`。以下非空响应仅为契约示例,不代表这些政策已上线。
响应示例:
```json
{
"code": 200,
"message": "ok",
"data": [
{
"cityCode": "110000",
"provinceCode": "110000",
"provinceName": "北京市",
"cityName": "北京市",
"policyVersion": "2026-09-20-v1",
"policyEffectiveDate": "2026-07-01",
"dataLevel": "CITY"
}
]
}
```
## 3. 查询城市默认参数
`GET /salary-calculator/cities/{cityCode}`
返回城市级或省级回退的当前默认参数。计算请求必须原样携带响应中的 `policyVersion` 作为 `expectedPolicyVersion`。
## 4. 计算并保存
`POST /salary-calculator/calculations`
请求示例:
```json
{
"cityCode": "110000",
"grossMonthlySalary": 10000.00,
"monthIndex": 1,
"specialAdditionalDeduction": 0.00,
"expectedPolicyVersion": "2026-09-20-v1",
"idempotencyKey": "optional-client-key",
"overrides": {
"socialSecurityBase": 10000.00,
"housingFundBase": 10000.00,
"pensionRate": 0.08,
"medicalRate": 0.02,
"unemploymentRate": 0.005,
"housingFundRate": 0.12,
"seriousIllnessMonthlyFee": 0.00
}
}
```
- `cityCode`、`grossMonthlySalary`、`expectedPolicyVersion` 必填。
- `monthIndex` 默认 1,范围 1–12。
- `specialAdditionalDeduction` 默认 0。
- `idempotencyKey` 可选,最长 64 字符;同用户同键同规范化请求返回原历史,同键不同请求返回 `409/10604`。
- 覆盖基数必须位于对应城市范围内;比例为 0 时允许基数为 0。
成功响应:
```json
{
"code": 200,
"message": "ok",
"data": {
"historyId": 123,
"createdAt": "2026-09-20T16:00:00",
"result": {
"cityCode": "110000",
"cityName": "北京市",
"dataLevel": "CITY",
"policyVersion": "2026-09-20-v1",
"policyEffectiveDate": "2026-07-01",
"taxRulesVersion": "CN_IIT_2019_V1",
"estimate": true,
"monthIndex": 1,
"grossMonthlySalary": 10000.00,
"socialSecurityBase": 10000.00,
"housingFundBase": 10000.00,
"pension": 800.00,
"medical": 200.00,
"unemployment": 50.00,
"housingFund": 1200.00,
"currentMonthTax": 82.50,
"netSalary": 7667.50
}
}
}
```
## 5. 查询历史
`GET /salary-calculator/calculations?page=0&size=20`
- 仅返回当前用户的记录。
- 按创建时间倒序、历史 ID 倒序。
- `size` 最大 100。
`GET /salary-calculator/calculations/{id}`
返回输入、政策、覆盖项和结果三组完整 JSON 快照。访问他人历史统一返回 `404/10603`。
## 6. 删除历史
`DELETE /salary-calculator/calculations/{id}`
物理删除本人历史;成功后再次查询返回 `404/10603`。删除后原幂等键可重新使用。
## 7. 错误码
| HTTP | code | 含义 |
|---|---|---|
| 400 | 10602 | 工资、比例、基数或分页参数非法 |
| 401 | 401 | 未登录或 Token 无效 |
| 403 | 2002 | 已登录但不是 `STUDENT` |
| 404 | 10601 | 城市不存在、停用、未配置或政策未发布 |
| 404 | 10603 | 历史不存在或不属于当前用户 |
| 409 | 10604 | 幂等键冲突 |
| 409 | 10605 | 城市政策版本已变化 |
`10605` 响应会在 `data` 中返回最新 `policyVersion` 和 `policyEffectiveDate`,客户端应重新加载城市详情后再试算。
## 8. 计算口径
```text
社保基数 = 用户覆盖值,否则 min(max(税前月薪, 社保下限), 社保上限)
公积金基数 = 用户覆盖值,否则 min(max(税前月薪, 公积金下限), 公积金上限)
累计收入 = 税前月薪 × monthIndex
累计基本减除费用 = 5000 × monthIndex
累计专项附加扣除 = 月度专项附加扣除 × monthIndex
累计应纳税所得额 = max(累计收入 - 累计个人社保 - 累计公积金 - 累计大病费
- 累计基本减除费用 - 累计专项附加扣除, 0)
当月个税 = max(累计应纳税额 - 累计已预扣税额, 0)
到手工资 = max(税前月薪 - 个人养老 - 个人医疗 - 个人失业 - 个人公积金
- 大病固定费 - 当月个税, 0)
```
## 9. 政策核验与发布门禁
### 默认关闭
`app.salary-publication.approved-policies` 是城市编码到已核验政策版本的精确映射,默认空映射。`enabled=true`、`dataLevel=CITY`、种子数量或功能测试通过,均不等于政策已核验。V53 保留原字节,不修改种子或已有历史快照,也不新增迁移。
### 白名单配置
仅在完成核验和发布审批后,由部署人员通过外部 Spring 配置提供映射。例如下列内容只展示配置格式,不可直接照抄到生产:
```yaml
app:
salary-publication:
approved-policies:
"110000": "已核验记录对应的实际政策版本"
```
城市编码必须为六位数字,版本不能为空或包含首尾空白;非法配置启动失败。城市和数据库当前版本都必须精确匹配,版本变更后旧白名单不会自动批准新版本。不提供全量开放或按 CITY/PROVINCE_FALLBACK 批量开放的开关。
测试类显式配置的种子版本只用于算法、响应契约、并发与回滚验证,不可作为上线授权或政策准确性证据。生产默认配置没有任何批准项。
### 前端行为
- 城市列表默认 `HTTP 200 / code=200 / data=[]`;前端显示“薪资估算暂未开放”,不要自行回退到未经核验的本地参数。
- 未发布城市的详情和新计算返回 `HTTP 404 / code=10601`,使用既有固定错误文案,不泄露政策参数。
- 相同幂等键的历史重放也经过发布门禁;不能利用 POST 重放绕过关闭状态。
- 只有已发布政策才会进入原有版本冲突检查;未发布时不返回最新政策版本信息。
### 历史兼容
本人历史列表、详情和删除接口不受发布白名单影响,继续执行 STUDENT 角色与记录归属检查。历史快照仅表示当时的估算结果,不证明其政策准确,不能将其当成当前政策使用。
### 核验记录要求
每个拟开放城市必须补齐官方来源地址、发布主体、适用地区、有效期、社保和公积金基数与比例、固定费用、核验人和核验日期,并记录与数据库 `policyVersion` 的对应关系。省级回退数据还需核实适用性并明确展示估算限制。更正已提交政策应使用后续新增迁移,不改写 V1–V53。
当前 359 条种子的政策准确性尚未由本次测试证明,因此本轮不添加任何生产批准项;N03/N04 不受此门禁影响。合并、部署及部署后三中间件黑盒验收另行执行。

275
docs/skill-cloud-api.md

@ -0,0 +1,275 @@
# 行业技能词云、个人匹配度与学习路径 API
> 版本:N03 设计锁定版
> 状态:已编码实现
> 适用范围:学生端只读接口。本文描述 `GET /api/v1/skills/word-cloud`、`GET /api/v1/skills/match`、`GET /api/v1/skills/learning-path`。
> Base URL:`http://127.0.0.1:8080/api/v1`(生产环境替换域名,路径不变)。
## 1. 权限与共同参数
三个接口均:
- 仅允许 `STUDENT` 访问。
- 必须携带 `Authorization: Bearer <accessToken>`,身份只从 JWT/SecurityContext 获取,不接受 `userId`、`studentId` 等身份注入参数。
- 成功、`400` 和 `403` 响应使用统一 `ApiResponse`;匿名或无效 JWT 的 `401` 由全局 Security 入口直接返回裸 JSON,不包含 `data` 和 `requestId`。
- 只读,不写入学习进度、学生资料或词库数据。
查询参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `industryId` | long | 是 | 词库行业 ID,必须为正整数,且必须是真实存在的行业。 |
| `occupationId` | long | 是 | 词库职位 ID,必须为正整数,且必须属于该行业。 |
| `categoryId` | long | 否 | 技能分类 ID,必须为正整数;省略时使用该职位下全部技能分类。传入时必须属于目标职位。 |
任一必填参数缺失、非正整数,或行业—职位—分类关系非法时返回 `400`。如果未登录返回 `401`,非 STUDENT 用户返回 `403`。
## 2. 通用响应与排序
三个接口的 `data` 都使用 camelCase 字段。词云和 TOP10 使用同一份服务端排序结果:
1. `heat` 从高到低;`heat` 为 `null` 的技能排在所有非空热度之后。
2. 热度相同时按 `nameZh` 升序。
3. 其余情况按 `skillId` 升序,保证重复请求顺序稳定。
4. `rank` 从 1 连续递增。
`heat` 来自 O*NET Importance。无可靠 O*NET Importance 映射的 ESCO 技能仍返回,但 `heat` 为 `null`。TOP10 不在后端单独存储或单独排序;前端直接取词云 `data.items` 的前 `min(10, data.total)` 项。
技能身份来源:
- `sourceType`:`ESCO` 或 `O*NET`,表示技能条目的主来源。
- `sourceCode`:可追溯来源标识。ESCO 为技能 URI;O*NET 补充技能为 `SOC|Workplace Example|Element ID`。
- `heatSourceCode`:热度来源 SOC;没有 O*NET 热度时为 `null`。
- `heatElementId`:O*NET Importance 的来源 Element ID;没有时为 `null`。
- `heatProxy`:该条热度是否为代理映射。代理值不改变排序规则,但前端可据此展示来源说明。
## 3. 查询词云
```http
GET /api/v1/skills/word-cloud?industryId=1&occupationId=2&categoryId=101
Authorization: Bearer <accessToken>
```
`categoryId` 可省略:
```http
GET /api/v1/skills/word-cloud?industryId=1&occupationId=2
```
成功响应为 `ApiResponse<SkillCloudResponse>`。`SkillCloudResponse` 返回筛选节点、总数和当前范围内的完整技能列表:
```json
{
"code": 200,
"message": "ok",
"data": {
"industry": { "id": 1, "code": "INTERNET", "name": "互联网" },
"occupation": { "id": 1, "code": "FRONTEND_DEVELOPER", "name": "前端开发工程师" },
"category": { "id": 103, "code": "FRONTEND_FRAMEWORK", "name": "前端框架" },
"total": 1,
"items": [
{
"rank": 1,
"skillId": 26,
"skillCode": "REACT",
"nameZh": "React",
"nameEn": "React",
"sourceType": "O*NET",
"sourceCode": "15-1254.00|React|2.E.7.h",
"categoryId": 103,
"categoryName": "前端框架",
"heat": 4.38,
"heatSourceCode": "15-1254.00",
"heatElementId": "2.E.7.h",
"heatProxy": true
}
]
},
"requestId": "req-123",
"timestamp": "2026-09-15T10:00:00Z"
}
```
`category` 在未传 `categoryId` 时为 `null`。合法范围但暂时没有技能时返回成功、`total=0`、`items=[]`。前端根据 `items` 渲染词云,并截取前十条作为 TOP10。
## 4. 查询个人技能匹配度
```http
GET /api/v1/skills/match?industryId=1&occupationId=2&categoryId=101
Authorization: Bearer <accessToken>
```
匹配集合严格取当前筛选结果的 TOP10。系统从当前 JWT 学生的 `student_profiles.skills_json` 读取技能,使用标准技能名、英文名、技能代码和别名归一化匹配。大小为 `total` 的筛选范围使用 `min(10, total)` 作为分母:
```text
matchScore = round(matchedCount * 100 / min(10, total))
```
- `total=0` 时 `matchScore=0`,命中列表和缺失列表为空。
- 范围少于 10 项时按实际技能数计算。
- 重复技能或指向同一标准技能的多个别名只计一次。
- 示例:范围 15 条、命中 5 条,`matchScore=33`。
- 原始 `skills_json`、手机号、邮箱和简历内容不会出现在响应中。
成功响应为 `ApiResponse<SkillMatchResponse>`:
```json
{
"code": 200,
"message": "ok",
"data": {
"matchScore": 50,
"matchedCount": 1,
"totalCount": 2,
"matchedSkills": [
{
"rank": 1,
"skillId": 26,
"skillCode": "REACT",
"nameZh": "React",
"nameEn": "React",
"sourceType": "O*NET",
"sourceCode": "15-1254.00|React|2.E.7.h",
"categoryId": 103,
"categoryName": "前端框架",
"heat": 4.38,
"heatSourceCode": "15-1254.00",
"heatElementId": "2.E.7.h",
"heatProxy": true
}
],
"missingSkills": [
{
"rank": 2,
"skillId": 31,
"skillCode": "DOCKER",
"nameZh": "Docker",
"nameEn": "Docker",
"sourceType": "O*NET",
"sourceCode": "15-1299.08|Docker|2.E.7.h",
"categoryId": 105,
"categoryName": "工程化工具",
"heat": 4.12,
"heatSourceCode": "15-1299.08",
"heatElementId": "2.E.7.h",
"heatProxy": true
}
]
},
"requestId": "req-456",
"timestamp": "2026-09-15T10:01:00Z"
}
```
`matchedSkills` 与 `missingSkills` 的并集等于当前 TOP10,两个列表各自保持词云排序;`totalCount` 是实际匹配集合大小。
## 5. 生成学习路径
```http
GET /api/v1/skills/learning-path?industryId=1&occupationId=2&categoryId=101
Authorization: Bearer <accessToken>
```
学习路径只消费个人匹配结果中的 `missingSkills`:
- 只包含当前 TOP10 中未命中的技能,不包含已命中技能或 TOP10 之外的技能。
- 顺序与 `missingSkills` 完全一致,`priority` 从 1 连续递增。
- 每项只返回技能 ID、名称、分类、优先级和简化学习建议。
- 后端不调用 AI、课程平台或外部知识库;不会返回课程链接、讲师、价格、学习时长或完成度。
- 系统不持久化学习进度或完成状态。
- 全部命中或合法范围没有技能时返回 `total=0`、`items=[]`。
建议模板按技能分类确定性生成:
| 模板类别 | 建议方向 |
|---|---|
| 编程语言 | 基础语法、常见数据结构、至少一个编码练习。 |
| 框架 | 核心概念与生命周期、小型项目集成实践。 |
| 工具 | 安装与基础配置、结合工程完成实际操作。 |
| 通用 | 核心概念和常见应用场景、简短实践巩固。 |
```json
{
"code": 200,
"message": "ok",
"data": {
"total": 1,
"items": [
{
"priority": 1,
"skillId": 26,
"skillName": "React",
"categoryId": 103,
"categoryName": "前端框架",
"suggestion": "先理解 React 的核心概念与生命周期,再通过一个小型项目练习集成。"
}
]
},
"requestId": "req-789",
"timestamp": "2026-09-15T10:02:00Z"
}
```
## 6. 错误响应示例
`400` 和 `403` 使用统一 `ApiResponse`;`401` 由全局 Security 入口返回裸 JSON。以下示例与运行时契约一致。
```json
{
"code": 1001,
"message": "bad request",
"data": null,
"requestId": "req-400",
"timestamp": "2026-09-15T10:03:00Z"
}
```
| HTTP | 场景 | 典型 `code` |
|---|---|---|
| `400` | `industryId`/`occupationId` 缺失、非正整数,或节点关系非法 | `1001` |
| `401` | 未携带有效 JWT 或令牌无效/过期 | `401` |
| `403` | 已登录但不是 STUDENT | `2002` |
示例 401:
```json
{
"code": 401,
"message": "not authenticated",
"timestamp": ""
}
```
`401` 响应不包含 `data` 和 `requestId`。
示例 403:
```json
{
"code": 2002,
"message": "forbidden",
"data": null,
"requestId": "req-403",
"timestamp": "2026-09-15T10:05:00Z"
}
```
## 7. 字段参考
### SkillCloudNode
`id`、`code`、`name`。`category` 节点在未指定分类时为 `null`。
### SkillCloudItem
`rank`、`skillId`、`skillCode`、`nameZh`、`nameEn`、`sourceType`、`sourceCode`、`categoryId`、`categoryName`、`heat`、`heatSourceCode`、`heatElementId`、`heatProxy`。
### SkillMatchResponse
`matchScore`、`matchedCount`、`totalCount`、`matchedSkills`、`missingSkills`。
### SkillLearningPathItem / SkillLearningPathResponse
`SkillLearningPathItem`:`priority`、`skillId`、`skillName`、`categoryId`、`categoryName`、`suggestion`。
`SkillLearningPathResponse`:`total`、`items`。

79
docs/skill-taxonomy-sources.md

@ -0,0 +1,79 @@
# 行业技能词库来源与覆盖说明
本说明是仓库面向实现和评审的技能词库来源基线,内容与 `changes/n03-skill-cloud/data/source-manifest.md` 保持同一事实集合。
## 1. 固定来源
| 来源 | 固定版本 | 用途 | 许可/复用条件 | 官方地址与归属 |
| --- | --- | --- | --- | --- |
| ESCO | ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0) | 提供行业/职位映射所需的职业—技能—技能分类主关系、稳定来源 ID 和层级结构 | 依据 Commission Decision 2011/833/EU,可免费下载、使用、复制和复用。文档和产品界面须保留官方署名语句:`This service uses the ESCO classification of the European Commission.` | 数据集下载页:https://esco.ec.europa.eu/en/use-esco/download<br>复用条件说明:https://esco.ec.europa.eu/en/about-esco/faq |
| O*NET | O*NET 31.0 Database | 为 ESCO 未收录的具体技术提供 `software_skills` 补充来源,并通过职业/工作活动 Importance 提供首版词云热度 `heat` | License 为 CC BY 4.0,归属与许可说明见 https://creativecommons.org/licenses/by/4.0/ | 官方数据库页:https://www.onetcenter.org/database.html |
运行时不得访问 ESCO、O*NET 或任何在线词库服务。所有首版数据必须随 Flyway 迁移进入本地数据库,并保留来源版本与归属信息。
## 2. 首版覆盖边界
首版只覆盖互联网、金融、教育三类行业及下列 10 个已列代表性职位。该矩阵定义导入上限和最小覆盖范围,不要求在全量上游数据中导入未列出的职位。
| 行业 | 代表性职位 | 首批技能分类范围 |
| --- | --- | --- |
| 互联网 | 前端开发工程师 | 编程语言、Web 基础、前端框架、工程化工具、浏览器与调试、测试、版本控制与协作 |
| 互联网 | 后端开发工程师 | 编程语言、后端框架与平台、数据库、缓存与消息、云/DevOps、测试、工程化工具、安全 |
| 互联网 | 测试工程师 | 编程语言、测试设计、自动化测试框架、接口与数据库测试、性能测试、CI/CD 与工程化工具 |
| 互联网 | DevOps 工程师 | 编程语言与脚本、云平台、容器与编排、CI/CD、配置管理与 IaC、可观测性、安全、数据库 |
| 金融 | 金融数据分析师 | 编程语言、数据分析与可视化、数据库与数据仓库、统计与建模、金融计算框架/平台、金融领域知识、工程化工具 |
| 金融 | 风控建模工程师 | 编程语言、统计与建模、风险模型、建模框架/平台、数据处理与数据库、模型验证、部署与工程化工具 |
| 金融 | 量化开发工程师 | 编程语言、量化建模框架/平台、市场与金融数据、数据库、低延迟与高性能工程、测试、云/DevOps |
| 教育 | 教育产品经理 | 教育领域知识、产品分析方法、需求与流程、数据分析、教育平台与工具、协作与工程化工具 |
| 教育 | 教学设计师 | 教学设计、课程与学习平台、数字化内容工具、评估设计、学习数据分析、无障碍与规范 |
| 教育 | 教育数据分析师 | 编程语言、数据分析与可视化、数据库与 SQL、统计与建模、教育领域知识、报表与工程化工具 |
计数说明:任务给出的逐项职位清单为互联网 4 个、金融 3 个、教育 3 个,共 10 个。验收文字中的“11 个职位”与该枚举不一致;本清单严格覆盖逐项列出的 10 个职位,不自行新增未批准职位。
矩阵中的分类是每个职位首批应覆盖的能力域;T3 只导入这些分类下已确定稳定来源 ID、标准技能和中文标准名的技能,不在此文档中展开具体 `INSERT` 行。
## 3. 导入规则
1. **职责分工**:T2 只建立七张词库表、外键、唯一约束和查询索引;T3 只导入上述三类行业、10 个已列代表性职位及其关联技能分类、技能、别名和 O*NET Importance。
2. **代表性子集与混合来源**:不导入 ESCO/O*NET 全量数据;ESCO 是职位、通用技能和分类主表;ESCO 无可靠节点的具体技术使用 O*NET `software_skills` 补充;不把平台自由文本岗位或行业直接当作标准词库节点。
3. **来源 ID 不变**:ESCO/O*NET 来源 ID 原样保存在稳定来源字段中;项目不得重编号、覆盖或伪造上游 ID。
4. **中文标准名必填**:每个入选技能必须有非空 `name_zh` 中文标准名;中文名称由项目维护,但不替代或修改上游来源 ID。
5. **热度来源**:每条非空 Importance 必须保存 O*NET SOC、Element ID 和代理标记;技能无法映射 O*NET Importance 时必须保留,写入 `importance=null`;查询时映射为 `heat=null`,并排在有热度技能之后。
6. **项目映射责任**:项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。行业/职位映射、细粒度分类和跨源技能对应关系均作为项目适配层记录。
7. **别名归一化**:生成 `normalized_alias` 时依次执行 Unicode NFKC、首尾空白移除、大小写折叠、连字符转空格、连续空白折叠为单空格、句点移除;`+` 和 `#` 必须保留,不能删除或替换。
8. **来源可追溯**:来源清单写入 `skill_source_versions`,至少记录来源名称、固定版本、许可、归属 URL 和导入时间;O*NET 补充技术的 `source_code` 使用真实 `SOC|Workplace Example|Element ID` 组合,职位技能关系记录 O*NET SOC、Element ID、代理标记及关联的来源版本。
9. **迁移不可改写**:T2/T3 计划使用 `V47__skill_cloud_taxonomy.sql` 和 `V48__skill_cloud_seed.sql`。执行前若编号已被占用,必须回退规划并刷新编号;后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或任何已执行迁移。
10. **运行时离线**:接口运行时不得调用外部词库、AI、课程平台或同步服务;排名只读取本地 Flyway 数据。
## 4. 来源版本、许可与归属 URL
| 数据落点 | 来源名称 | 版本字段固定值 | 许可字段固定值 | 归属 URL |
| --- | --- | --- | --- | --- |
| 职业、技能、技能分类及关系 | ESCO | `1.2.1 (Release v5.7.0, Last update 10/12/2025)` | `Commission Decision 2011/833/EU` | https://esco.ec.europa.eu/en/use-esco/download |
| 职位技能 Importance | O*NET | `31.0 Database` | `CC BY 4.0` | https://www.onetcenter.org/database.html |
ESCO 产品署名必须使用:`This service uses the ESCO classification of the European Commission.`
## 5. 映射责任边界
| 事项 | 上游来源责任 | 项目责任 |
| --- | --- | --- |
| ESCO 职业、技能、分类层级和来源 ID | 固定且可追溯 | 原样保存,不修改 ID |
| O*NET Importance 数值 | 固定且可追溯 | 原样保存;无映射时写 `null`,不造权重 |
| 中文名称和别名 | 不保证完整中文覆盖 | 补齐中文标准名、别名及归一化值 |
| 细粒度技能分类 | 不保证符合本项目展示粒度 | 在职位下维护分类和展示名称 |
| 行业/职位映射 | 不保证覆盖三类项目行业和中文职位名 | 维护行业—职位—分类—技能映射 |
| 跨源技能与重要性关联 | 不提供项目所需的稳定本地关系 | 维护 ESCO 技能到 O*NET `software_skills` 的补充映射,以及每条 Importance 的 SOC/Element ID/代理标记 |
## 6. 验收检查项
- AC-1:`changes/n03-skill-cloud/data/source-manifest.md` 与 `docs/skill-taxonomy-sources.md` 均存在且非空,来源版本、许可、归属 URL、覆盖矩阵和导入规则一致。
- AC-2:文档明确覆盖互联网、金融、教育三类行业及全部 10 个已列代表性职位,并为每个职位给出首批技能分类范围。
- AC-3:文档明确固定 ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0)和 O*NET 31.0 Database。
- AC-4:文档明确 ESCO 依据 Commission Decision 2011/833/EU 可免费下载、使用、复制和复用,并原样保留指定署名语句。
- AC-5:文档明确 O*NET License 为 CC BY 4.0,并记录官方数据库页与许可 URL。
- AC-6:文档明确项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。
- AC-7:文档明确 T2 建结构、T3 导数据,只导入代表性职位和关联技能;技能中文标准名必填。
- AC-8:文档明确 O*NET 补充技术、每条 Importance 的 SOC/Element ID/代理标记,以及无映射时保留技能并写入 `importance=null`、查询侧映射为 `heat=null`。
- AC-9:文档明确别名归一化遵循 NFKC、首尾空白、大小写、连字符、连续空白、句点规则,且保留 `+`、`#`。
- AC-10:文档明确后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或已执行迁移。
- AC-11:来源、覆盖、导入与责任边界中不得出现未决占位或延期确认语句。

866
docs/tools/b01_demo_display_data.py

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

594
docs/tools/b01_demo_display_data_test.py

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

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

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

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

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

388
docs/verify_openapi.py

@ -6,10 +6,34 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) print('paths:', len(d['paths']))
assert len(d['paths']) == 182, f"Expected 182 paths, got {len(d['paths'])}" assert len(d['paths']) == 221, f"Expected 221 paths, got {len(d['paths'])}"
paths = d['paths'] paths = d['paths']
# AI-001:优化 POST 与只读告知 GET,保留全部既有模块。
optimization = paths['/profile/me/work-experience-optimization']['post']
assert optimization.get('operationId') == 'optimizeWorkExperience'
assert {'bearerAuth': []} in optimization.get('security', [])
assert [] not in optimization['security'] and {} not in optimization['security']
assert optimization['requestBody'].get('required') is True
optimization_schema = optimization['requestBody']['content']['application/json']['schema']
assert {'description', 'externalProcessingConsent', 'consentNoticeVersion'} <= set(optimization_schema['required'])
assert optimization_schema['properties']['externalProcessingConsent'].get('type') == 'boolean'
assert optimization_schema['properties']['externalProcessingConsent']['enum'] == [True]
assert all(type(value) is bool for value in optimization_schema['properties']['externalProcessingConsent']['enum'])
assert optimization_schema['properties']['consentNoticeVersion']['enum'] == ['work-experience-external-processing-v2']
assert optimization_schema['properties']['consentNoticeVersion'].get('type') == 'string'
notice = paths['/profile/me/work-experience-optimization/notice']['get']
assert notice['operationId'] == 'getWorkExperienceOptimizationNotice'
assert notice['security'] == [{'bearerAuth': []}]
assert {'200', '401', '403', '404'} <= set(notice['responses'])
for code in ('400', '401', '403', '404', '429', '502', '503', '504'):
assert code in optimization['responses'], f'Missing optimization response {code}'
assert optimization['responses']['429']['headers']['Retry-After']['schema']['type'] == 'integer'
optimization_data = optimization['responses']['200']['content']['application/json']['schema']['properties']['data']
assert optimization_data['properties']['simulated']['type'] == 'boolean'
assert {'simulated', 'promptVersion'} <= set(optimization_data['required'])
# Generic OpenAPI integrity checks. These catch importer-breaking structures # Generic OpenAPI integrity checks. These catch importer-breaking structures
# before business-specific path/count assertions run. # before business-specific path/count assertions run.
HTTP_METHODS = {'get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace'} HTTP_METHODS = {'get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace'}
@ -62,7 +86,9 @@ assert used_tags <= declared_tags, f'Undeclared operation tags: {sorted(used_tag
required = [ required = [
# Phase A 企业注册与入驻聚合(2 个 path / 2 个 operation) # Phase A 企业注册与入驻聚合(2 个 path / 2 个 operation)
'/auth/company/register', '/auth/company/register',
'/auth/identity/select',
'/companies/me/onboarding', '/companies/me/onboarding',
'/companies/me/jobs',
'/review-weight-templates', '/review-weight-templates',
'/profile/me/review-weights', '/profile/me/review-weights',
'/profile/me/privacy', '/profile/me/privacy',
@ -647,7 +673,7 @@ assert _auth_paths == {
'/auth/sms/send', '/auth/login', '/auth/me', '/auth/sms/send', '/auth/login', '/auth/me',
'/auth/refresh', '/auth/logout', '/auth/refresh', '/auth/logout',
'/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset', '/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset',
'/auth/company/register', '/auth/company/register', '/auth/identity/select', '/auth/password/setup', '/auth/me/avatar',
}, f'Unexpected /auth paths: {sorted(_auth_paths)}' }, f'Unexpected /auth paths: {sorted(_auth_paths)}'
print(' S02 refresh/logout and password authentication operations validated') print(' S02 refresh/logout and password authentication operations validated')
@ -674,6 +700,18 @@ for _forbidden in ('materialsJson', 'decisionReason', 'reviewerId', 'storagePath
assert _forbidden not in _onboarding_text assert _forbidden not in _onboarding_text
print(' Phase A company registration/onboarding paths and redacted schemas validated') print(' Phase A company registration/onboarding paths and redacted schemas validated')
# 企业岗位管理:私有全状态查询与公开岗位列表保持不同的安全边界。
assert set(paths['/companies/me/jobs']) == {'get'}
_company_jobs = paths['/companies/me/jobs']['get']
assert _company_jobs['operationId'] == 'getMyJobs'
assert {p['name'] for p in _company_jobs['parameters']} == {'status', 'keyword', 'city', 'page', 'size'}
assert _company_jobs['responses']['200']['content']['*/*']['schema']['$ref'] \
== '#/components/schemas/ApiResponsePageResponseCompanyManagedJobItem'
assert 'bearerAuth' in json.dumps(_company_jobs.get('security', []))
_company_job_properties = resolve_schema(d['components']['schemas']['CompanyManagedJobItem'])['properties']
assert {'id', 'companyId', 'title', 'city', 'salaryMin', 'salaryMax', 'status'} <= set(_company_job_properties)
print(' 企业自身全状态岗位列表路径、认证要求和强类型响应已验证')
# ── S03:唯一新增的企业简历下载接口 ──────────────────────────────── # ── S03:唯一新增的企业简历下载接口 ────────────────────────────────
_download_path = '/company/candidates/{applicationId}/resume' _download_path = '/company/candidates/{applicationId}/resume'
assert _download_path in paths, 'Missing company resume download endpoint' assert _download_path in paths, 'Missing company resume download endpoint'
@ -702,17 +740,359 @@ for _p in paths:
assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}' assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}'
print(' S03 company resume download contract validated; candidate DTOs remain redacted') print(' S03 company resume download contract validated; candidate DTOs remain redacted')
# 统计 operation 总数并校验 V43 + N07 + S02 + S03 + Phase A 计数 220 # 统计 operation 总数并校验当前接口契约基线。
operations = sum(len(methods) for methods in d['paths'].values()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) print('operations:', operations)
assert operations == 220, f"Expected 220 operations, got {operations}" assert operations == 271, f"Expected 271 operations, got {operations}"
# 个人头像/企业 Logo 独立写入;文件上传不接受客户端 userId 或 URL。
for _route, _upload, _delete in (
('/auth/me/avatar', 'uploadAvatar', 'deleteAvatar'),
('/companies/me/logo', 'uploadLogo', 'deleteLogo'),
):
assert set(paths[_route]) == {'post', 'delete'}
assert paths[_route]['post']['operationId'] == _upload
assert paths[_route]['delete']['operationId'] == _delete
for _method in ('post', 'delete'):
assert paths[_route][_method]['security'] == [{'bearerAuth': []}]
_image_body = paths[_route]['post']['requestBody']
assert _image_body['required'] is True
_image_schema = _image_body['content']['multipart/form-data']['schema']
assert _image_schema['required'] == ['file']
assert set(_image_schema['properties']) == {'file'}
assert _image_schema['properties']['file']['format'] == 'binary'
_image_get = paths['/profile-images/{imageId}']['get']
assert _image_get['operationId'] == 'readProfileImage'
assert _image_get['security'] == [] # Logo 公开;个人头像读取在服务端重新鉴权。
assert {'200', '401', '403', '404'} <= set(_image_get['responses'])
assert 'image/jpeg' in _image_get['responses']['200']['content']
assert _image_get['responses']['200']['headers']['Cache-Control']['schema']['enum'] == ['no-store']
assert 'post' in paths['/conversations/{id}/resume-messages']
assert {'get', 'delete'} <= paths['/conversations/{conversationId}/messages/{messageId}/resume'].keys()
assert {'messageType', 'resume'} <= d['components']['schemas']['MessageResponse']['properties'].keys()
assert paths['/auth/password/setup']['post']['operationId'] == 'setupPassword'
assert 'bearerAuth' in json.dumps(paths['/auth/password/setup']['post']['security'])
# 统一账号身份选择:只允许两种业务身份,保持旧登录字段引用。
_select = paths['/auth/identity/select']['post']
assert _select['requestBody']['content']['application/json']['schema']['$ref'] == '#/components/schemas/IdentitySelectionRequest'
assert set(d['components']['schemas']['IdentitySelectionRequest']['properties']['identity']['enum']) == {'STUDENT', 'COMPANY'}
assert 'identitySelectionRequired' in d['components']['schemas']['IdentitySelectionResponse']['properties']
assert 'reauthenticationRequired' in d['components']['schemas']['IdentitySelectionResponse']['properties']
print(" Offer advance endpoints all present") print(" Offer advance endpoints all present")
print(" 26 operations all present") print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)") print(" Account deletion endpoints present (3 operations)")
schemas = {name.removeprefix('Lxq'): schema for name, schema in
d['components']['schemas'].items() if name.startswith('Lxq')}
onboarding_success_refs = {
('/onboarding/companies/{companyId}/checklist', 'get'): 'ApiResponseEffectiveChecklistResponse',
('/onboarding/companies/{companyId}/checklist/items/{itemId}/completion', 'put'): 'ApiResponseProgressUpdateResponse',
('/onboarding/companies/{companyId}/knowledge', 'get'): 'ApiResponseCompanyKnowledgeOverviewResponse',
('/onboarding/company/checklist-items', 'get'): 'ApiResponseListCompanyChecklistItemView',
('/onboarding/company/checklist-items', 'post'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/checklist-items/{itemId}', 'put'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/checklist-items/{itemId}', 'delete'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/knowledge', 'get'): 'ApiResponseCompanyKnowledgeView',
('/onboarding/company/knowledge', 'put'): 'ApiResponseCompanyKnowledgeView',
}
for (path, method), schema_name in onboarding_success_refs.items():
response = paths[path][method]['responses']['200']
ref = response['content']['*/*']['schema']['$ref']
assert ref == f'#/components/schemas/Lxq{schema_name}', f'Wrong success schema for {method.upper()} {path}: {ref}'
for status_code in ('401', '403'):
assert status_code in paths[path][method]['responses'], f'Missing {status_code} for {method.upper()} {path}'
unauthorized = paths[path][method]['responses']['401']
assert unauthorized['content']['application/json']['schema']['$ref'] == '#/components/schemas/LxqUnauthorizedResponse'
forbidden = paths[path][method]['responses']['403']
assert forbidden['content']['application/json']['schema']['$ref'] == '#/components/schemas/LxqApiResponse'
assert forbidden['content']['application/json']['example']['code'] == 2002
onboarding_schema_fields = {
'CompanyChecklistItemCreateRequest': {'title', 'description', 'category', 'sortOrder', 'required', 'idempotencyKey'},
'CompanyChecklistItemUpdateRequest': {'title', 'description', 'category', 'sortOrder', 'required', 'active', 'version'},
'CompanyChecklistItemView': {'itemId', 'source', 'itemKey', 'title', 'description', 'category', 'required', 'sortOrder', 'active', 'version'},
'EffectiveChecklistResponse': {'companyId', 'companyName', 'customized', 'totalItems', 'completedItems', 'completionRate', 'items'},
'ChecklistItemView': {'itemId', 'source', 'itemKey', 'title', 'description', 'category', 'required', 'sortOrder', 'active', 'completed', 'completedAt', 'progressVersion'},
'ProgressUpdateRequest': {'completed', 'version'},
'ProgressUpdateResponse': {'itemId', 'completed', 'completedAt', 'progressVersion', 'totalItems', 'completedItems', 'completionRate'},
'CompanyKnowledgeUpdateRequest': {'culture', 'workPolicies', 'onboardingGuide', 'version'},
'CompanyKnowledgeView': {'configured', 'culture', 'workPolicies', 'onboardingGuide', 'updatedAt', 'version'},
'CompanyKnowledgeOverviewResponse': {'company', 'knowledgeState', 'knowledge', 'reviewSummary'},
'CompanyView': {'id', 'name', 'logoUrl', 'description', 'industry', 'website', 'address', 'verified'},
'KnowledgeView': {'culture', 'workPolicies', 'onboardingGuide', 'updatedAt', 'version'},
'ReviewSummaryView': {'state', 'totalReviews', 'averageRating', 'ratingDistribution', 'topTags', 'dimensionSummary'},
'DimensionSummaryView': {'averageScore', 'sampleCount', 'dataState'},
}
for schema_name, expected_fields in onboarding_schema_fields.items():
schema = schemas.get(schema_name)
assert schema is not None, f'Missing onboarding schema: {schema_name}'
assert expected_fields <= set(schema.get('properties', {})), (
f'Onboarding schema fields missing: {schema_name}'
)
create_request = schemas['CompanyChecklistItemCreateRequest']['properties']
assert create_request['sortOrder'].get('default') == 1000, (
'CompanyChecklistItemCreateRequest.sortOrder default must be 1000'
)
assert create_request['required'].get('default') is False, (
'CompanyChecklistItemCreateRequest.required default must be false'
)
knowledge_success_example = paths['/onboarding/company/knowledge']['put']['responses']['200']['content']['*/*']['example']
assert knowledge_success_example['data'].get('version') == 2, (
'PUT /onboarding/company/knowledge 200 example data.version must be 2'
)
onboarding_error_paths = {
10501: ('/onboarding/company/checklist-items/{itemId}', 'put', '404'),
10502: ('/onboarding/company/checklist-items/{itemId}', 'put', '409'),
10503: ('/onboarding/company/checklist-items', 'post', '409'),
10504: ('/onboarding/company/checklist-items', 'post', '409'),
10505: ('/onboarding/companies/{companyId}/checklist/items/{itemId}/completion', 'put', '409'),
10506: ('/onboarding/company/knowledge', 'put', '409'),
}
for error_code, (path, method, status_code) in onboarding_error_paths.items():
response_text = json.dumps(paths[path][method]['responses'][status_code], ensure_ascii=False)
assert str(error_code) in response_text, f'Missing onboarding error code {error_code} in {method.upper()} {path}'
print(' N04 onboarding endpoints and schemas present (9 operations / 6 paths)')
# ── 岗位与企业展示字段批次:新增字段、约束与公开摘要白名单 ─────────────
# 本批未新增端点(paths=201 / operations=245 基线不变),只扩展既有 schema 与响应说明。
# 运行时为 OpenAPI 3.1,可空性以 `type: [<base>, "null"]` 表达(不再使用 3.0 的 nullable)。
def _type_set(node):
value = node.get('type')
return set(value) if isinstance(value, list) else {value}
_display_text_fields = {'experience': 40, 'education': 40, 'workLocation': 255}
for _schema_name in ('JobCreateRequest', 'JobUpdateRequest'):
_props = d['components']['schemas'][_schema_name]['properties']
for _field, _max in _display_text_fields.items():
assert _field in _props, f'{_schema_name} missing display field {_field}'
assert _type_set(_props[_field]) == {'string', 'null'}, \
f'{_schema_name}.{_field} 必须为可空 string'
assert _props[_field]['maxLength'] == _max, f'{_schema_name}.{_field} maxLength must be {_max}'
_benefits = _props['benefits']
assert _type_set(_benefits) == {'array', 'null'}, f'{_schema_name}.benefits must be nullable array'
assert _benefits['items']['type'] == 'string', f'{_schema_name}.benefits items must be string'
assert 'maxItems' not in _benefits and 'maxLength' not in _benefits['items'], '请求原始福利不得施加归一化后的限制'
assert _benefits.get('x-normalization') == dict(trimItems=True, deduplicate=True, rejectBlankItems=True,
maxItems=20, itemMaxLength=40), '福利归一化契约缺失或错误'
_months = _props['salaryMonths']
assert _type_set(_months) == {'integer', 'null'}, \
f'{_schema_name}.salaryMonths must be nullable integer (非整数不允许)'
assert _months['minimum'] == 1 and _months['maximum'] == 24, \
f'{_schema_name}.salaryMonths must be 1..24'
for _salary in ('salaryMin', 'salaryMax'):
_desc = _props[_salary].get('description', '')
assert '元' in _desc, f'{_schema_name}.{_salary} 单位仍应为元/月'
print(' 岗位展示字段 experience/education/benefits/salaryMonths/workLocation 约束已验证')
for _schema_name in ('CompanyCreateRequest', 'CompanyUpdateRequest'):
_cs = d['components']['schemas'][_schema_name]['properties']['companySize']
assert _type_set(_cs) == {'string', 'null'}, f'{_schema_name}.companySize must be nullable string'
assert _cs['maxLength'] == 40, f'{_schema_name}.companySize maxLength must be 40'
assert '空白' in _cs.get('description', ''), f'{_schema_name}.companySize 写入语义(空白清空)须写明'
print(' companySize 请求字段约束与写入语义已验证')
_managed_props = d['components']['schemas']['CompanyManagedJobItem']['properties']
for _field in ('experience', 'education', 'benefits', 'salaryMonths', 'workLocation'):
assert _field in _managed_props, f'CompanyManagedJobItem missing display field {_field}'
assert '元' in _managed_props['salaryMin'].get('description', '')
assert '元' in _managed_props['salaryMax'].get('description', '')
print(' CompanyManagedJobItem 展示字段已验证')
_public_company_props = d['components']['schemas']['CompanyPublicView']['properties']
assert set(_public_company_props) == {
'id', 'name', 'logoUrl', 'description', 'industry', 'verified', 'companySize'}, \
f'CompanyPublicView 必须严格为 7 字段白名单,实际={sorted(_public_company_props)}'
for _forbidden in ('ownerUserId', 'address', 'website', 'phone', 'email', 'createdAt',
'materialsJson', 'decisionReason', 'reviewerId'):
assert _forbidden not in _public_company_props, f'CompanyPublicView 不得包含 {_forbidden}'
print(' 公开企业摘要严格 7 字段白名单已验证(不含 ownerUserId/联系方式/地址/认证材料/审核信息)')
for _pt, _method in [('/jobs', 'get'), ('/jobs', 'post'), ('/jobs/search', 'get'),
('/jobs/public/{jobId}', 'get'), ('/jobs/{jobId}', 'get'),
('/jobs/{jobId}', 'put'), ('/companies/{companyId}/jobs', 'get')]:
_desc = paths[_pt][_method]['responses']['200'].get('description', '')
assert all(_f in _desc for _f in ('experience', 'education', 'benefits', 'salaryMonths', 'workLocation')), \
f'{_method.upper()} {_pt} 200 说明须列出岗位展示字段'
assert '元/月' in _desc, f'{_method.upper()} {_pt} 200 说明须写明薪单位仍为元/月'
_company_summary_desc = paths['/companies/{companyId}']['get']['responses']['200'].get('description', '')
assert 'companySize' in _company_summary_desc and 'ownerUserId' in _company_summary_desc
print(' Map 型岗位接口与公开企业摘要的展示字段说明已验证')
data = json.dumps(d) data = json.dumps(d)
assert '"" : "#/components/schemas/' not in data, "Found empty ref key" assert '"" : "#/components/schemas/' not in data, "Found empty ref key"
print("No empty ref keys found") print("No empty ref keys found")
_search_parameters = {p['name']: p for p in paths['/jobs/search']['get']['parameters']}
for _name in ('industry', 'experience', 'education'):
assert _search_parameters[_name]['in'] == 'query'
assert _search_parameters[_name]['required'] is False
assert _search_parameters[_name]['schema']['type'] == 'string'
for _option in ('industries', 'experiences', 'educations'):
assert _option in paths['/jobs/filters']['get']['description']
print(' 岗位行业/经验/学历多选参数和动态选项说明已验证')
_online = '/profile/me/online-resume'
assert set(paths[_online]) & HTTP_METHODS == {'get', 'put'}
assert set(paths[_online + '/{section}']) & HTTP_METHODS == {'get', 'post'}
assert set(paths[_online + '/{section}/{id}']) & HTTP_METHODS == {'get', 'put', 'delete'}
assert 'get' in paths[_online + '/download']
_online_schemas = d['components']['schemas']
assert {'phone', 'email'} <= set(_online_schemas['OnlineResumeBasicInfo']['properties'])
assert {'educationExperiences', 'workExperiences', 'projectExperiences', 'skills', 'selfEvaluation'} <= set(_online_schemas['OnlineResumeContent']['properties'])
assert {'jobSeekingStatus', 'employmentStatus', 'availableFrom'} <= set(_online_schemas['OnlineResumeJobIntention']['properties'])
assert {'fileName', 'contentType', 'size', 'downloadUrl'} <= set(_online_schemas['OnlineResumeAttachment']['properties'])
assert '6002' in paths[_online]['put']['description']
_entry = _online_schemas['OnlineResumeEntryRequest']['properties']['entry']
assert '$ref' not in _entry
assert len(_entry['oneOf']) == 4
assert len({item['$ref'] for item in _entry['oneOf']}) == 4
for _candidate in ('CandidateItemView', 'CandidateDetailView'):
assert not ({'phone', 'email'} & set(_online_schemas[_candidate]['properties']))
print(' Online resume: 4 paths / 8 operations, contacts and experience schemas verified')
_work_type = _online_schemas['OnlineResumeWork']['properties']['experienceType']
assert set(_work_type['type']) == {'string', 'null'}
assert _work_type['pattern'] == '^(FULL_TIME|INTERNSHIP)$'
assert 'experienceType' not in _online_schemas['OnlineResumeWork'].get('required', [])
assert '未区分' in _work_type['description']
for _legacy_schema in ('StudentProfileUpdateRequest', 'StudentPreferenceRequest'):
_schema = _online_schemas[_legacy_schema]
_revision = _schema['properties']['revision']
assert _revision['type'] == 'integer' and _revision['format'] == 'int64'
assert _revision['minimum'] == 0
assert '409/6002' in _revision['description']
assert 'revision' not in _schema.get('required', []), '无在线记录的旧客户端仍可省略版本'
for _legacy_path in ('/profile/me', '/profile/me/preferences'):
assert '409/6002' in paths[_legacy_path]['put']['description']
assert 'revision' in paths[_legacy_path]['put']['description']
print(' Legacy profile/preference writes share online resume revision; conditional requirement documented')
_map_schemas = d['components']['schemas']
_map_request = _map_schemas['JobMapLocationRequest']
_map_view = _map_schemas['JobMapLocationView']
assert set(_map_request['type']) == {'object', 'null'}
assert set(_map_request['required']) == {'status'}
assert set(_map_view['required']) == {'provider', 'status'}
for _schema in (_map_request, _map_view):
_props = _schema['properties']
assert set(_props['status']['enum']) == {'UNSET', 'PENDING_CONFIRMATION', 'CONFIRMED'}
for _axis, _limit in (('longitude', 180), ('latitude', 90)):
assert set(_props[_axis]['type']) == {'number', 'null'}
assert _props[_axis]['minimum'] == -_limit and _props[_axis]['maximum'] == _limit
assert _props[_axis]['multipleOf'] == 0.000001
assert set(_props['coordinateSystem']['type']) == {'string', 'null'}
assert _props['coordinateSystem']['pattern'] == '^GCJ02$'
assert 'enum' not in _props['coordinateSystem'], 'nullable 坐标系不能被非空 enum 排除 null'
assert _props['poiId']['maxLength'] == 80
assert _props['poiName']['maxLength'] == 120
assert _map_view['properties']['provider']['enum'] == ['AMAP']
for _model, _ref in (('JobCreateRequest', 'JobMapLocationRequest'),
('JobUpdateRequest', 'JobMapLocationRequest'),
('JobDetailViewDoc', 'JobMapLocationView'),
('JobPublicViewDoc', 'JobMapLocationView')):
assert 'mapLocation' in _map_schemas[_model]['properties'], f'{_model} 缺少 mapLocation'
assert _map_schemas[_model]['properties']['mapLocation']['$ref'] == '#/components/schemas/' + _ref
assert 'mapLocation' not in _map_schemas[_model].get('required', [])
assert 'UNSET' in _map_schemas['JobUpdateRequest']['properties']['mapLocation']['description']
assert not any('/map-preview' in _path for _path in paths), '本批不提供静态图接口'
print(' Job map location: optional grouped coordinates, GCJ02, status and detail contracts verified')
_search_path = '/companies/me/job-location-suggestions'
assert _search_path in paths and set(paths[_search_path]) == {'get'}
_search = paths[_search_path]['get']
assert {p['name'] for p in _search['parameters']} == {'city', 'keyword'}
for _name, _min, _max in (('city', 1, 40), ('keyword', 2, 80)):
_param = next(p for p in _search['parameters'] if p['name'] == _name)
assert _param['required'] is True and _param['in'] == 'query'
assert _param['schema']['minLength'] == _min and _param['schema']['maxLength'] == _max
assert {'200', '400', '401', '403', '404', '429', '503'} <= set(_search['responses'])
assert '9001' in _search['responses']['503']['description']
assert 'Retry-After=60' in _search['responses']['429']['description']
assert '不写岗位' in _search['description']
assert '不代表真实办公地址已经平台核验' in _search['description']
_suggestion = _map_schemas['JobLocationSuggestion']
assert set(_suggestion['required']) == {'poiId', 'poiName', 'address', 'city', 'district',
'longitude', 'latitude', 'coordinateSystem', 'provider'}
assert _suggestion['properties']['coordinateSystem']['enum'] == ['GCJ02']
assert _suggestion['properties']['provider']['enum'] == ['AMAP']
for _axis, _limit in (('longitude', 180), ('latitude', 90)):
_field = _suggestion['properties'][_axis]
assert _field['type'] == 'number' and _field['minimum'] == -_limit and _field['maximum'] == _limit
assert _field['multipleOf'] == 0.000001
for _name, _max in (('poiId', 80), ('poiName', 120), ('address', 255)):
assert _suggestion['properties'][_name]['maxLength'] == _max
_search_response = resolve_local_ref(d, _search['responses']['200']['content']['application/json']['schema']['$ref'])
_items = resolve_local_ref(d, _search_response['properties']['data']['$ref'])['properties']['items']
assert _items['type'] == 'array' and _items.get('maxItems') == 10
assert _items['items']['$ref'] == '#/components/schemas/JobLocationSuggestion'
print(' Company job-location suggestions: typed candidates, bounded query and error contract verified')
_accept = paths['/applications/{id}/accept']['post']
assert {'200', '401', '403', '404', '409'} <= set(_accept['responses'])
assert not _accept.get('requestBody'), 'Acceptance must not accept caller-provided userId/companyId/scores'
assert '不修改status' in _accept['description'] and '幂等' in _accept['description']
_accept_success = next(iter(_accept['responses']['200']['content'].values()))['schema']
_accept_envelope = resolve_local_ref(d, _accept_success['$ref'])
_accept_view = resolve_local_ref(d, _accept_envelope['properties']['data']['$ref'])
assert set(_accept_view['properties']) == {'applicationId', 'status', 'companyAcceptedAt', 'communicationAllowed'}
assert _accept_view['properties']['communicationAllowed']['type'] == 'boolean'
assert _accept_view['properties']['companyAcceptedAt']['format'] == 'date-time'
assert 'communicationAllowed' in paths['/applications/{id}/match']['get']['description']
assert '无需双向评分达标' in paths['/applications/{id}/conversation']['post']['description']
print(' Company acceptance unlocks communication; typed response and capability contract verified')
_attachment_paths = {
'/conversations/{id}/attachment-messages': 'post',
'/conversations/{conversationId}/messages/{messageId}/attachment': 'get',
'/conversations/{conversationId}/messages/{messageId}/attachment/preview': 'get',
'/conversations/{conversationId}/messages/{messageId}/attachment/thumbnail': 'get',
}
for _path, _method in _attachment_paths.items():
assert _method in paths[_path]
_upload = paths['/conversations/{id}/attachment-messages']['post']
_multipart = _upload['requestBody']['content']['multipart/form-data']['schema']
if '$ref' in _multipart:
_multipart = resolve_local_ref(d, _multipart['$ref'])
assert 'file' in _multipart['properties']
assert {'kind', 'clientMessageId'} <= {p['name'] for p in _upload['parameters']}
_message = d['components']['schemas']['MessageResponse']
_attachment = resolve_local_ref(d, _message['properties']['attachment']['$ref'])
assert set(_attachment['properties']) == {'fileName','contentType','size','sha256','width','height','available','downloadPath','previewPath','thumbnailPath'}
assert _attachment['properties']['available']['type'] == 'boolean'
print(' Chat image/file multipart, authenticated binary reads and typed metadata verified')
_notification_content = paths['/notifications']['get']['responses']['200']['content']
_notification_envelope = resolve_local_ref(d, next(iter(_notification_content.values()))['schema']['$ref'])
_notification_page = resolve_local_ref(d, _notification_envelope['properties']['data']['$ref'])
assert set(_notification_page['properties']) == {'items', 'total', 'page', 'size', 'totalPages'}
_notification_item = resolve_local_ref(d, _notification_page['properties']['items']['items']['$ref'])
assert set(_notification_item['properties']) == {'id', 'type', 'title', 'content', 'read', 'readAt', 'createdAt', 'businessType', 'businessId'}
assert {'businessType', 'businessId'} <= set(_notification_item['required'])
for _field, _type in [('businessType', 'string'), ('businessId', 'integer')]:
assert set(_notification_item['properties'][_field]['type']) == {_type, 'null'}
assert _notification_item['properties']['businessId']['format'] == 'int64'
assert 'enum' not in _notification_item['properties']['businessType'], 'Unknown persisted business types must remain compatible'
assert _notification_item['properties']['read']['type'] == 'boolean'
for _path, _method in [('/notifications/{id}/read', 'post'), ('/notifications/read-all', 'post'), ('/notifications/unread-count', 'get')]:
assert _method in paths[_path]
print(' Notification association fields: typed, nullable, no internal fields; existing read operations preserved')
_trash_methods = {'/notifications/{id}': ('delete', 'deleteNotification'),
'/notifications/{id}/restore': ('post', 'restoreNotification'),
'/notifications/trash': ('get', 'listNotificationTrash'),
'/notifications/batch-delete': ('post', 'batchDeleteNotifications'),
'/notifications/batch-restore': ('post', 'batchRestoreNotifications')}
for _path, (_method, _operation) in _trash_methods.items():
_op = paths[_path][_method]
assert _op['operationId'] == _operation
assert _op['security'] == [{'bearerAuth': []}]
assert '200' in _op['responses']
assert not any('/notifications/' in p and ('purge' in p or 'clear' in p) for p in paths)
_batch = d['components']['schemas']['NotificationBatchRequest']
assert _batch['required'] == ['ids']
assert _batch['properties']['ids']['minItems'] == 1 and _batch['properties']['ids']['maxItems'] == 100
assert _batch['properties']['ids']['items']['type'] == 'integer'
assert _batch['properties']['ids']['items']['format'] == 'int64'
assert _batch['properties']['ids']['items']['minimum'] == 1
_trash_item = d['components']['schemas']['NotificationTrashItemDoc']
assert set(_trash_item['properties']) == set(_notification_item['properties']) | {'deletedAt'}
assert _trash_item['properties']['deletedAt']['format'] == 'date-time'
assert 'deletedAt' in _trash_item['required']
print(' Notification recycle bin: five authenticated operations, bounded batch IDs, deletion time; no permanent deletion')
print("Valid JSON: True") print("Valid JSON: True")
print("All checks passed!") print("All checks passed!")

4
mydreamjob-server/README.md

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

12
mydreamjob-server/pom.xml

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

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

@ -20,8 +20,11 @@ import java.time.LocalDateTime;
@AllArgsConstructor @AllArgsConstructor
@Entity @Entity
@Table(name = "applications", @Table(name = "applications",
uniqueConstraints = @UniqueConstraint(name = "uk_applications_job_student", uniqueConstraints = {
columnNames = {"job_id", "student_user_id"})) @UniqueConstraint(name = "uk_applications_job_student_attempt",
columnNames = {"job_id", "student_user_id", "attempt_number"}),
@UniqueConstraint(name = "uk_applications_job_student_active",
columnNames = {"job_id", "student_user_id", "active_application_key"})})
@EntityListeners(AuditingEntityListener.class) @EntityListeners(AuditingEntityListener.class)
public class Application { public class Application {
@ -35,6 +38,17 @@ public class Application {
@Column(name = "student_user_id", nullable = false) @Column(name = "student_user_id", nullable = false)
private Long studentUserId; private Long studentUserId;
@Column(name = "attempt_number", nullable = false)
@Builder.Default
private Integer attemptNumber = 1;
@Column(name = "previous_application_id")
private Long previousApplicationId;
/** 数据库按状态生成;被拒绝、已录用仍占位,不能借重投绕过终态规则。 */
@Column(name = "active_application_key", insertable = false, updatable = false)
private Integer activeApplicationKey;
@Column(name = "cover_letter", columnDefinition = "text") @Column(name = "cover_letter", columnDefinition = "text")
private String coverLetter; private String coverLetter;
@ -42,6 +56,10 @@ public class Application {
@Builder.Default @Builder.Default
private String status = "SUBMITTED"; private String status = "SUBMITTED";
/** 企业明确接受本轮投递;查看简历、评分、旧轮投递均不自动写入。 */
@Column(name = "company_accepted_at")
private LocalDateTime companyAcceptedAt;
@CreatedDate @CreatedDate
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;

41
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationAcceptanceController.java

@ -0,0 +1,41 @@
package com.mydreamjob.application;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
@RestController
@RequiredArgsConstructor
@Tag(name = "投递")
public class ApplicationAcceptanceController {
private final ApplicationAcceptanceService acceptance;
private final CurrentUserAccessor users;
private final RequestIdAccessor requestIds;
public record AcceptanceView(Long applicationId, String status, LocalDateTime companyAcceptedAt,
boolean communicationAllowed) {}
@Operation(summary = "企业接受投递并解锁沟通", description = "仅已认证的岗位所属企业;无请求体。SUBMITTED/VIEWED/INTERVIEW/HIRED 可接受,拒绝/撤回返回409。接受与招聘状态分离,不修改status、不填写双方评分;幂等返回原companyAcceptedAt。成功后双方可调用 POST /applications/{id}/conversation,无需双向评分达标。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="200",description="data 包含 applicationId、status、companyAcceptedAt、communicationAllowed=true"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="401",description="未登录或会话不可用"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="403",description="角色、企业认证或投递归属不符"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="404",description="投递/企业不存在"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="409",description="投递已拒绝或撤回,不能接受")
})
@PostMapping("/applications/{id}/accept")
public ResponseEntity<ApiResponse<AcceptanceView>> accept(@PathVariable Long id, HttpServletRequest request) {
Application app = acceptance.accept(users.requireUserId(),id,AuditContext.from(request));
AcceptanceView view = new AcceptanceView(app.getId(),app.getStatus(),app.getCompanyAcceptedAt(),true);
return ResponseEntity.ok(ApiResponse.ok(view,requestIds.get(request)));
}
}

58
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationAcceptanceService.java

@ -0,0 +1,58 @@
package com.mydreamjob.application;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.notification.NotificationService;
import com.mydreamjob.security.UserRoleGuard;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class ApplicationAcceptanceService {
private final UserRoleGuard roles;
private final CompanyRepository companies;
private final ApplicationRepository applications;
private final JobService jobs;
private final ApplicationStatusHistoryRepository history;
private final AuditService audit;
private final NotificationService notifications;
private final Clock clock;
/** 与撤回/状态推进使用相同投递行锁;重试只产生一次接受记录和通知。 */
@Transactional
public Application accept(Long actorUserId, Long applicationId, AuditContext context) {
roles.requireCompany(actorUserId);
Company company = VerifiedCompanyGuard.requireVerified(companies.findByOwnerUserId(actorUserId).orElse(null));
Application app = applications.findLockedById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobs.getJob(app.getJobId());
if (!company.getId().equals(job.getCompanyId()))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
if (!Set.of("SUBMITTED","VIEWED","INTERVIEW","HIRED").contains(app.getStatus()))
throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION);
// 已录用仍可补记明确接受;已经接受时不覆盖原始时间/审计。
if (app.getCompanyAcceptedAt() != null) return app;
app.setCompanyAcceptedAt(LocalDateTime.now(clock).truncatedTo(java.time.temporal.ChronoUnit.MICROS));
app = applications.save(app);
history.save(ApplicationStatusHistory.builder().applicationId(app.getId())
.operatorUserId(actorUserId).fromStatus(app.getStatus()).toStatus(app.getStatus())
.reason("COMPANY_ACCEPTED: company accepted application; communication unlocked").build());
audit.log(actorUserId,"ACCEPT_APPLICATION","APPLICATION",app.getId(),
"Company accepted application; communication unlocked",context);
notifications.publishEvent(app.getStudentUserId(),"APPLICATION_ACCEPTED","企业已接受投递",
"企业已接受你的投递,可以开始沟通","APPLICATION",app.getId(),"accept-application-"+app.getId());
return app;
}
}

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

@ -0,0 +1,16 @@
package com.mydreamjob.application;
import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecord;
/** 沟通资格与双向评分解耦;终止投递优先拒绝,历史匹配/已录用保持兼容。 */
public final class ApplicationCommunicationPolicy {
private ApplicationCommunicationPolicy() {}
public static boolean allows(Application app, ApplicationMatchRecord legacyMatch) {
if (app == null || app.getStatus() == null
|| !java.util.Set.of("SUBMITTED","VIEWED","INTERVIEW","HIRED").contains(app.getStatus()))
return false;
return app.getCompanyAcceptedAt() != null || "HIRED".equals(app.getStatus())
|| (legacyMatch != null && ApplicationMatchRecord.STATUS_MATCHED.equals(legacyMatch.getStatus()));
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -287,6 +287,10 @@ public class AuthSessionService {
if (userId == null || sessionId == null || sessionId.isBlank()) { if (userId == null || sessionId == null || sessionId.isBlank()) {
throw new InvalidTokenException(); throw new InvalidTokenException();
} }
// 与登录、刷新和密码变更统一 users -> auth_tokens 锁顺序。
// 必须先等刷新事务提交,再开始撤销语句,避免遗漏尚未可见的后继 Token。
// 不新增 active 校验,保留已失效/不存在会话的幂等登出语义。
userRepository.findByIdForUpdate(userId);
authTokenRepository.revokeSession(userId, sessionId, toLocal(capturedNow), REASON_LOGOUT); authTokenRepository.revokeSession(userId, sessionId, toLocal(capturedNow), REASON_LOGOUT);
auditService.logSessionRevoked(userId, sessionRef(sessionId), REASON_LOGOUT, ctx, toLocal(capturedNow)); auditService.logSessionRevoked(userId, sessionRef(sessionId), REASON_LOGOUT, ctx, toLocal(capturedNow));
} }
@ -336,7 +340,7 @@ public class AuthSessionService {
return authTokenHasher.hash("sid:" + sessionId).substring(0, 16); return authTokenHasher.hash("sid:" + sessionId).substring(0, 16);
} }
static LocalDateTime toLocal(Instant instant) { public static LocalDateTime toLocal(Instant instant) {
return LocalDateTime.ofInstant(instant, BUSINESS_ZONE); return LocalDateTime.ofInstant(instant, BUSINESS_ZONE);
} }
} }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java

@ -51,7 +51,7 @@ public class ApplicationMatchController {
return ResponseEntity.ok(ApiResponse.ok(toView(record), requestIdAccessor.get(request))); return ResponseEntity.ok(ApiResponse.ok(toView(record), requestIdAccessor.get(request)));
} }
@Operation(summary = "查询投递匹配状态", description = "仅投递求职者或岗位所属企业;不暴露内部路径/事件 ID") @Operation(summary = "查询投递匹配状态", description = "仅投递求职者或岗位所属企业;不暴露内部路径/事件 ID。附 companyAcceptedAt(明确接受时间,可null)和 communicationAllowed(沟通资格);接受后不要求双向评分达标。匹配 status=CLOSED 可能由 HIRED 引起,不代表 communicationAllowed=false;最终仍校验会话归属、企业认证与屏蔽。")
@GetMapping("/applications/{id}/match") @GetMapping("/applications/{id}/match")
public ResponseEntity<ApiResponse<Map<String, Object>>> getMatch( public ResponseEntity<ApiResponse<Map<String, Object>>> getMatch(
@PathVariable Long id, @PathVariable Long id,

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

@ -13,9 +13,18 @@ public interface ApplicationMatchRecordRepository extends JpaRepository<Applicat
Optional<ApplicationMatchRecord> findByApplicationId(Long applicationId); Optional<ApplicationMatchRecord> findByApplicationId(Long applicationId);
/** 企业(companyId)与候选人(candidateId)之间是否存在已 MATCHED 的投递(用于建会话守卫) */ /** 撤回后的历史直聊只保留阅读,重新发送必须重新取得有效匹配。 */
@Query("SELECT COUNT(r) > 0 FROM ApplicationMatchRecord r " @Query("SELECT COUNT(a) > 0 FROM Application a JOIN Job j ON j.id = a.jobId "
+ "WHERE r.companyId = :companyId AND r.candidateId = :candidateId AND r.status = 'MATCHED'") + "WHERE j.companyId = :companyId AND a.studentUserId = :candidateId AND a.status = 'WITHDRAWN'")
boolean existsWithdrawnApplication(@Param("companyId") Long companyId,
@Param("candidateId") Long candidateId);
/** 沟通资格;方法名保留给旧调用方,语义已扩展为企业接受/已录用/历史匹配。 */
@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 IN ('SUBMITTED', 'VIEWED', 'INTERVIEW', 'HIRED') "
+ "AND (a.companyAcceptedAt IS NOT NULL OR a.status = 'HIRED' "
+ "OR EXISTS (SELECT r.id FROM ApplicationMatchRecord r WHERE r.applicationId = a.id AND r.status = 'MATCHED'))")
boolean existsMatchedApplication(@Param("companyId") Long companyId, boolean existsMatchedApplication(@Param("companyId") Long companyId,
@Param("candidateId") Long candidateId); @Param("candidateId") Long candidateId);

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

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

4
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java

@ -13,7 +13,7 @@ import java.util.Map;
/** /**
* 匹配后沟通解锁接口:基于 application 创建/获取直聊会话 * 匹配后沟通解锁接口:基于 application 创建/获取直聊会话
*/ */
@Tag(name = "双向匹配沟通", description = "匹配成功后创建或获取直聊会话(幂等)") @Tag(name = "双向匹配沟通", description = "企业接受投递后创建或获取直聊会话(幂等),兼容历史匹配")
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
public class MatchConversationController { public class MatchConversationController {
@ -25,7 +25,7 @@ public class MatchConversationController {
private final MatchConversationService matchConversationService; private final MatchConversationService matchConversationService;
@Operation(summary = "创建/获取匹配会话", description = "仅 MATCHED 双方可调用;重复调用返回同一会话") @Operation(summary = "创建/获取投递沟通会话", description = "仅投递双方;企业接受后即可沟通,无需双向评分达标;历史 MATCHED 或 HIRED 保持兼容。已拒绝/撤回投递返回409/8304,未接受同样返回409/8304;重复调用返回同一会话。企业认证、归属、屏蔽校验保留。GET /applications/{id}/match 的 communicationAllowed 为沟通资格,不应从匹配 status=CLOSED 推断禁止沟通。")
@PostMapping("/applications/{id}/conversation") @PostMapping("/applications/{id}/conversation")
public ResponseEntity<ApiResponse<Map<String, Object>>> createOrGetConversation( public ResponseEntity<ApiResponse<Map<String, Object>>> createOrGetConversation(
@PathVariable Long id, @PathVariable Long id,

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

@ -6,6 +6,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService; import com.mydreamjob.job.JobService;
import com.mydreamjob.messaging.Conversation; import com.mydreamjob.messaging.Conversation;
@ -23,7 +24,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* 匹配后沟通解锁:仅 MATCHED 的双方可基于 application 创建/获取直聊会话。 * 企业接受后沟通解锁;兼容历史 MATCHED 和已录用,拒绝/撤回优先禁止。
* *
* <p>幂等:application_match_conversations 唯一约束保证同一投递只解锁一个会话, * <p>幂等:application_match_conversations 唯一约束保证同一投递只解锁一个会话,
* 重复调用返回同一会话。不修改通用 POST /conversations 的会话结构。 * 重复调用返回同一会话。不修改通用 POST /conversations 的会话结构。
@ -40,10 +41,13 @@ public class MatchConversationService {
private final ApplicationMatchConversationRepository conversationLinkRepository; private final ApplicationMatchConversationRepository conversationLinkRepository;
private final ConversationRepository conversationRepository; private final ConversationRepository conversationRepository;
private final ConversationMemberRepository memberRepository; private final ConversationMemberRepository memberRepository;
private final com.mydreamjob.safety.BlockRelationRepository blockRelationRepository;
@Transactional @Transactional
public Map<String, Object> createOrGetMatchConversation(Long userId, Long applicationId) { public Map<String, Object> createOrGetMatchConversation(Long userId, Long applicationId) {
Application app = applicationRepository.findById(applicationId) Company ownedCompany = companyRepository.findByOwnerUserId(userId).orElse(null);
if (ownedCompany != null) VerifiedCompanyGuard.requireVerified(ownedCompany);
Application app = applicationRepository.findLockedById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId()); Job job = jobService.getJob(app.getJobId());
Long companyId = job.getCompanyId(); Long companyId = job.getCompanyId();
@ -53,20 +57,20 @@ public class MatchConversationService {
Long otherUserId; Long otherUserId;
if (candidateId.equals(userId)) { if (candidateId.equals(userId)) {
otherUserId = companyRepository.findById(companyId) otherUserId = companyRepository.findById(companyId)
.map(VerifiedCompanyGuard::requireVerified)
.map(Company::getOwnerUserId) .map(Company::getOwnerUserId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
} else { } else {
Long actorCompanyId = companyRepository.findByOwnerUserId(userId) Long actorCompanyId = ownedCompany == null ? null : ownedCompany.getId();
.map(Company::getId).orElse(null);
if (actorCompanyId == null || !actorCompanyId.equals(companyId)) { if (actorCompanyId == null || !actorCompanyId.equals(companyId)) {
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
} }
otherUserId = candidateId; otherUserId = candidateId;
} }
// 必须已 MATCHED // 与撤回/状态推进共用当前读;不能仅根据匹配查询的 CLOSED 展示值判断沟通权限。
ApplicationMatchRecord record = recordRepository.findByApplicationId(applicationId).orElse(null); ApplicationMatchRecord record = recordRepository.findByApplicationId(applicationId).orElse(null);
if (record == null || !ApplicationMatchRecord.STATUS_MATCHED.equals(record.getStatus())) { if (!com.mydreamjob.application.ApplicationCommunicationPolicy.allows(app, record)) {
throw new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED); throw new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED);
} }
@ -78,6 +82,10 @@ public class MatchConversationService {
} }
// 创建/复用两人之间的 DIRECT 会话(与 MessagingService 语义一致) // 创建/复用两人之间的 DIRECT 会话(与 MessagingService 语义一致)
if (blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(userId, otherUserId)
|| blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(otherUserId, userId)) {
throw new BusinessException(ErrorCode.FORBIDDEN, "message cannot be sent");
}
Conversation conversation = findOrCreateDirectConversation(userId, otherUserId); Conversation conversation = findOrCreateDirectConversation(userId, otherUserId);
try { try {
conversationLinkRepository.save(ApplicationMatchConversation.builder() conversationLinkRepository.save(ApplicationMatchConversation.builder()

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

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

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

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

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

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

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

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

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

@ -37,9 +37,12 @@ public enum ErrorCode {
COMPANY_NOT_FOUND(5001, "company not found"), COMPANY_NOT_FOUND(5001, "company not found"),
COMPANY_OWNER_MISMATCH(5002, "company owner mismatch"), COMPANY_OWNER_MISMATCH(5002, "company owner mismatch"),
COMPANY_ALREADY_EXISTS(5003, "company already exists"), COMPANY_ALREADY_EXISTS(5003, "company already exists"),
COMPANY_NOT_VERIFIED(5004, "company not verified"),
// 学生资料 6xxx // 学生资料 6xxx
PROFILE_NOT_FOUND(6001, "profile not found"), PROFILE_NOT_FOUND(6001, "profile not found"),
ONLINE_RESUME_REVISION_CONFLICT(6002, "online resume changed; refresh before saving"),
ONLINE_RESUME_ENTRY_NOT_FOUND(6003, "online resume entry not found"),
// 岗位 7xxx // 岗位 7xxx
JOB_NOT_FOUND(7001, "job not found"), JOB_NOT_FOUND(7001, "job not found"),
@ -81,13 +84,14 @@ public enum ErrorCode {
// 双向匹配 83xx // 双向匹配 83xx
MATCH_CLOSED(8301, "match is closed"), MATCH_CLOSED(8301, "match is closed"),
MATCH_SCORES_LOCKED(8302, "match already succeeded, scores are locked"), MATCH_SCORES_LOCKED(8302, "match already succeeded, scores are locked"),
MATCH_REQUIRED_FOR_CONVERSATION(8303, "direct conversation between company and candidate requires a matched application"), MATCH_REQUIRED_FOR_CONVERSATION(8303, "direct conversation requires a company-accepted application"),
APPLICATION_NOT_MATCHED(8304, "application is not matched"), APPLICATION_NOT_MATCHED(8304, "application is not accepted for communication"),
// 站内信 83xx // 站内信 83xx
CONVERSATION_NOT_FOUND(8305, "conversation not found"), CONVERSATION_NOT_FOUND(8305, "conversation not found"),
MESSAGE_NOT_FOUND(8306, "message not found"), MESSAGE_NOT_FOUND(8306, "message not found"),
MESSAGE_RECALL_WINDOW_EXPIRED(8307, "message recall window expired"), MESSAGE_RECALL_WINDOW_EXPIRED(8307, "message recall window expired"),
MESSAGE_RECALL_CONCURRENT_UPDATE(8308, "message recall concurrent update"), MESSAGE_RECALL_CONCURRENT_UPDATE(8308, "message recall concurrent update"),
RESUME_MESSAGE_UNAVAILABLE(8309, "resume message is no longer available"),
// 评价 91xx // 评价 91xx
DUPLICATE_REVIEW(9101, "duplicate review"), DUPLICATE_REVIEW(9101, "duplicate review"),
@ -239,7 +243,22 @@ public enum ErrorCode {
COMPANY_REGISTRATION_DISABLED(10301, "company registration is disabled"), COMPANY_REGISTRATION_DISABLED(10301, "company registration is disabled"),
COMPANY_REGISTRATION_CONFLICT(10302, "company registration conflict"), COMPANY_REGISTRATION_CONFLICT(10302, "company registration conflict"),
COMPANY_ONBOARDING_INCONSISTENT_STATE(10303, "company onboarding state is inconsistent"), COMPANY_ONBOARDING_INCONSISTENT_STATE(10303, "company onboarding state is inconsistent"),
COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE(10304, "company registration dependency unavailable"); COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE(10304, "company registration dependency unavailable"),
IDENTITY_SELECTION_REQUIRED(10401, "identity selection is required"),
IDENTITY_SELECTION_CONFLICT(10402, "identity selection conflict"),
IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE(10403, "identity selection temporarily unavailable"),
ONBOARDING_CHECKLIST_ITEM_NOT_FOUND(10501, "onboarding checklist item not found"),
ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE(10502, "onboarding checklist item was modified concurrently"),
ONBOARDING_ITEM_LIMIT_EXCEEDED(10503, "onboarding checklist item limit exceeded"),
ONBOARDING_IDEMPOTENCY_CONFLICT(10504, "idempotency key conflict: request body differs"),
ONBOARDING_PROGRESS_CONCURRENT_UPDATE(10505, "onboarding progress was modified concurrently"),
ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE(10506, "company knowledge was modified concurrently"),
SALARY_CITY_NOT_FOUND(10601, "salary city not found or disabled"),
SALARY_INVALID_PARAMETER(10602, "invalid salary calculation parameter"),
SALARY_HISTORY_NOT_FOUND(10603, "salary calculation history not found"),
SALARY_IDEMPOTENCY_CONFLICT(10604, "salary idempotency key conflict: request body differs"),
SALARY_POLICY_VERSION_CONFLICT(10605, "salary city policy version conflict");
private final int code; private final int code;
private final String message; private final String message;

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

Loading…
Cancel
Save