Browse Source

feat: unify account registration and first identity selection

integration/lxq-n03-n05-20261008
Zhang Shun 4 weeks ago
parent
commit
32fb6470a4
  1. 51
      docs/openapi-rc.json
  2. 49
      docs/unified-registration-identity-selection-design.md
  3. 159
      docs/unified-registration-identity-selection-implementation-report.md
  4. 14
      docs/verify_openapi.py
  5. 5
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java
  6. 1
      mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java
  7. 4
      mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java
  8. 76
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java
  9. 31
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionRequest.java
  10. 8
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionResponse.java
  11. 104
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionService.java
  12. 1
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java
  13. 6
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  14. 3
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java
  15. 13
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  16. 22
      mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
  17. 4
      mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java
  18. 4
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  19. 3
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java
  20. 49
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/IdentityPolicyTest.java
  21. 205
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/IdentitySelectionMySqlTest.java
  22. 141
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityIndependentBoundaryTest.java
  23. 236
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java
  24. 213
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java
  25. 6
      mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java
  26. 10
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java
  27. 8
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java
  28. 18
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java
  29. 3
      mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java
  30. 3
      mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyOnboardingControllerTest.java

51
docs/openapi-rc.json

@ -189,6 +189,21 @@
}
],
"paths": {
"/auth/identity/select": {
"post": {
"tags": ["认证"],
"summary": "首次选择账号身份",
"description": "统一账号一次性选择 STUDENT 或 COMPANY;COMPANY 必须提交 company 资料且注册开关开启。首次成功撤销全部旧会话并返回新凭证;同身份重复请求只返回状态,不重复颁发凭证。",
"operationId": "selectIdentity",
"requestBody": {
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/IdentitySelectionRequest"}}},
"required": true
},
"responses": {
"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseIdentitySelectionResponse"}}}}
}
}
},
"/": {
"get": {
"tags": [
@ -1391,8 +1406,8 @@
"tags": [
"认证"
],
"summary": "Mock 登录",
"description": "使用手机号+验证码登录,返回 JWT token",
"summary": "手机号验证码登录",
"description": "手机号验证码登录;新手机号创建 UNASSIGNED 统一账号,identitySelectionRequired=true 时前端必须进入首次身份选择;旧角色保持不变。保留原字段及成功码 200。",
"operationId": "mockLogin",
"requestBody": {
"content": {
@ -9484,6 +9499,38 @@
},
"components": {
"schemas": {
"IdentitySelectionRequest": {
"type": "object",
"required": ["identity"],
"properties": {
"identity": {"type": "string", "enum": ["STUDENT", "COMPANY"]},
"company": {"$ref": "#/components/schemas/CompanyDetails"}
}
},
"IdentitySelectionResponse": {
"type": "object",
"properties": {
"userId": {"type": "integer", "format": "int64"},
"role": {"type": "string"},
"identitySelectionRequired": {"type": "boolean"},
"companyId": {"type": "integer", "format": "int64"},
"onboardingStatus": {"type": "string"},
"accessToken": {"type": "string"},
"refreshToken": {"type": "string"},
"expiresIn": {"type": "integer", "format": "int64"},
"reauthenticationRequired": {"type": "boolean"}
}
},
"ApiResponseIdentitySelectionResponse": {
"type": "object",
"properties": {
"code": {"type": "integer", "format": "int32"},
"message": {"type": "string"},
"data": {"$ref": "#/components/schemas/IdentitySelectionResponse"},
"requestId": {"type": "string"},
"timestamp": {"type": "string", "format": "date-time"}
}
},
"TaxonomyResponse": {
"type": "object",
"properties": {

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

14
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths']))
assert len(d['paths']) == 182, f"Expected 182 paths, got {len(d['paths'])}"
assert len(d['paths']) == 183, f"Expected 183 paths, got {len(d['paths'])}"
paths = d['paths']
@ -62,6 +62,7 @@ assert used_tags <= declared_tags, f'Undeclared operation tags: {sorted(used_tag
required = [
# Phase A 企业注册与入驻聚合(2 个 path / 2 个 operation)
'/auth/company/register',
'/auth/identity/select',
'/companies/me/onboarding',
'/review-weight-templates',
'/profile/me/review-weights',
@ -647,7 +648,7 @@ assert _auth_paths == {
'/auth/sms/send', '/auth/login', '/auth/me',
'/auth/refresh', '/auth/logout',
'/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset',
'/auth/company/register',
'/auth/company/register', '/auth/identity/select',
}, f'Unexpected /auth paths: {sorted(_auth_paths)}'
print(' S02 refresh/logout and password authentication operations validated')
@ -705,7 +706,14 @@ print(' S03 company resume download contract validated; candidate DTOs remain r
# 统计 operation 总数并校验 V43 + N07 + S02 + S03 + Phase A 计数 220
operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations)
assert operations == 220, f"Expected 220 operations, got {operations}"
assert operations == 221, f"Expected 221 operations, got {operations}"
# 统一账号身份选择:只允许两种业务身份,保持旧登录字段引用。
_select = paths['/auth/identity/select']['post']
assert _select['requestBody']['content']['application/json']['schema']['$ref'] == '#/components/schemas/IdentitySelectionRequest'
assert set(d['components']['schemas']['IdentitySelectionRequest']['properties']['identity']['enum']) == {'STUDENT', 'COMPANY'}
assert 'identitySelectionRequired' in d['components']['schemas']['IdentitySelectionResponse']['properties']
assert 'reauthenticationRequired' in d['components']['schemas']['IdentitySelectionResponse']['properties']
print(" Offer advance endpoints all present")
print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)")

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

@ -62,7 +62,8 @@ public class AuthService {
user = User.builder()
.phone(phone)
.nickname("用户_" + phone.substring(7))
.role(User.Role.STUDENT)
.role(User.Role.UNASSIGNED)
.roles(Set.of())
.active(true)
.verified(true)
.build();
@ -97,6 +98,7 @@ public class AuthService {
result.put("phone", user.getPhone());
result.put("nickname", user.getNickname());
result.put("role", user.getRole());
result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED);
result.put("accessToken", tokens.accessToken());
result.put("refreshToken", tokens.refreshToken());
result.put("expiresIn", EXPIRES_IN_SECONDS);
@ -137,6 +139,7 @@ public class AuthService {
result.put("nickname", user.getNickname());
result.put("avatarUrl", user.getAvatarUrl());
result.put("role", user.getRole());
result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED);
result.put("active", user.getActive());
result.put("verified", user.getVerified());
result.put("createdAt", user.getCreatedAt());

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

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

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

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

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/change", "/auth/login", "/auth/password/login",
"/auth/password/reset", "/auth/refresh", "/auth/sms/send", "/auth/company/register");
private static final Set<String> PUBLIC_GET = Set.of("/", "/health", "/health/**", "/jobs",
"/jobs/search", "/jobs/filters", "/jobs/compare", "/jobs/public/**", "/jobs/*/reviews",
"/jobs/*/reviews/*", "/jobs/*/reviews/summary", "/companies/*", "/companies/*/jobs",
"/job-recommendation-taxonomies", "/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**");
private final UserRepository users;
private final CurrentUserAccessor currentUser;
private final ObjectMapper mapper;
private final RequestIdAccessor requestIds;
private final AntPathMatcher paths = new AntPathMatcher();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 当前生产认证仅由 JwtAuthenticationFilter 建立;只使用其已校验的会话主体。
// 匿名请求继续交给既有 Security 入口,测试注入的非 JWT 主体不新增解析行为。
var authentication=org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
if(authentication==null || !(authentication.getDetails() instanceof com.mydreamjob.auth.SessionAuthenticationDetails)) {
chain.doFilter(request,response); return;
}
Long id;
try { id = currentUser.requireUserId(); }
catch (InsufficientAuthenticationException e) { chain.doFilter(request,response); return; }
User user;
try { user = users.findById(id).orElse(null); }
catch (org.springframework.dao.DataAccessException e) {
reject(request,response,503,ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE); return;
}
if (user == null || user.getRole() != User.Role.UNASSIGNED) {chain.doFilter(request,response); return;}
String uri = request.getRequestURI().substring(request.getContextPath().length());
if (allowed(request.getMethod(), uri)) {chain.doFilter(request,response); return;}
reject(request,response,403,ErrorCode.IDENTITY_SELECTION_REQUIRED);
}
private void reject(HttpServletRequest request, HttpServletResponse response, int status, ErrorCode error) throws IOException {
response.setStatus(status);
response.setContentType("application/json;charset=UTF-8");
mapper.writeValue(response.getWriter(), ApiResponse.error(error.getCode(),error.getMessage(), requestIds.get(request)));
}
boolean allowed(String method, String uri) {
if ("OPTIONS".equals(method) || paths.match("/actuator/**", uri)) return true;
if ("POST".equals(method)) return AUTH_POST.contains(uri);
if (!"GET".equals(method) && !"HEAD".equals(method)) return false;
// /companies/* 是公开企业 ID 路径,不能误把保留字 me 当作公开主页。
if ("/companies/me".equals(uri) || uri.startsWith("/companies/me/")) return false;
return "/auth/me".equals(uri) || PUBLIC_GET.stream().anyMatch(pattern -> paths.match(pattern, uri));
}
}

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

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

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

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

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

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

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

@ -59,6 +59,7 @@ public class PasswordAuthenticationService {
result.put("phone", user.getPhone());
result.put("nickname", user.getNickname());
result.put("role", user.getRole());
result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED);
result.put("accessToken", tokens.accessToken());
result.put("refreshToken", tokens.refreshToken());
result.put("expiresIn", AuthService.EXPIRES_IN_SECONDS);

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

@ -240,7 +240,11 @@ public enum ErrorCode {
COMPANY_REGISTRATION_DISABLED(10301, "company registration is disabled"),
COMPANY_REGISTRATION_CONFLICT(10302, "company registration conflict"),
COMPANY_ONBOARDING_INCONSISTENT_STATE(10303, "company onboarding state is inconsistent"),
COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE(10304, "company registration dependency unavailable");
COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE(10304, "company registration dependency unavailable"),
IDENTITY_SELECTION_REQUIRED(10401, "identity selection is required"),
IDENTITY_SELECTION_CONFLICT(10402, "identity selection conflict"),
IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE(10403, "identity selection temporarily unavailable");
private final int code;
private final String message;

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

@ -112,6 +112,7 @@ public final class ErrorHttpStatusRegistry {
.put(HttpStatus.FORBIDDEN,
ErrorCode.APPLICATION_OWNER_MISMATCH,
ErrorCode.COMPANY_NOT_VERIFIED,
ErrorCode.IDENTITY_SELECTION_REQUIRED,
ErrorCode.FORBIDDEN,
ErrorCode.INTERVIEW_ACCESS_DENIED,
ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION,
@ -146,6 +147,7 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.VERIFICATION_NOT_FOUND)
.put(HttpStatus.CONFLICT,
ErrorCode.PASSWORD_STATE_CONFLICT,
ErrorCode.IDENTITY_SELECTION_CONFLICT,
ErrorCode.ABILITY_ATTEMPT_INVALID_STATE,
ErrorCode.ABILITY_CONCURRENT_UPDATE,
ErrorCode.ABILITY_DUPLICATE_ACTIVE,
@ -227,6 +229,7 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE)
.put(HttpStatus.SERVICE_UNAVAILABLE,
ErrorCode.COMPANY_REGISTRATION_DISABLED,
ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE,
ErrorCode.COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE)
.put(HttpStatus.TOO_MANY_REQUESTS,
ErrorCode.PASSWORD_RATE_LIMITED,

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

@ -1,6 +1,8 @@
package com.mydreamjob.config;
import com.mydreamjob.auth.JwtAuthenticationFilter;
import com.mydreamjob.auth.identity.IdentityAccessFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import com.mydreamjob.audit.AuditService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpMethod;
@ -36,6 +38,14 @@ public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final AuditService auditService;
private final IdentityAccessFilter identityAccessFilter;
@Bean
public FilterRegistrationBean<IdentityAccessFilter> identityFilterRegistration() {
var registration = new FilterRegistrationBean<>(identityAccessFilter);
registration.setEnabled(false); // 只在 JWT 验证后的安全链执行。
return registration;
}
@Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:3000}")
private String allowedOrigins;
@ -154,7 +164,8 @@ public class SecurityConfig {
.requestMatchers("/").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(identityAccessFilter, JwtAuthenticationFilter.class);
return http.build();
}

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

@ -16,6 +16,9 @@ import com.mydreamjob.auth.password.PasswordAuthenticationService;
import com.mydreamjob.auth.company.CompanyRegistrationRequest;
import com.mydreamjob.auth.company.CompanyRegistrationResponse;
import com.mydreamjob.auth.company.CompanyRegistrationService;
import com.mydreamjob.auth.identity.IdentitySelectionService;
import com.mydreamjob.auth.identity.IdentitySelectionRequest;
import com.mydreamjob.auth.identity.IdentitySelectionResponse;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
@ -55,6 +58,21 @@ public class AuthController {
private final CurrentUserAccessor currentUserAccessor;
private final PasswordAuthenticationService passwordAuthenticationService;
private final CompanyRegistrationService companyRegistrationService;
private final IdentitySelectionService identitySelectionService;
@Operation(summary = "首次选择账号身份", description = "统一账号一次性选择求职者或企业,成功后替换旧登录凭证")
@PostMapping("/identity/select")
public ResponseEntity<ApiResponse<IdentitySelectionResponse>> selectIdentity(
@Valid @RequestBody IdentitySelectionRequest body, HttpServletRequest request) {
try {
var response = identitySelectionService.select(currentUserAccessor.requireUserId(),
currentUserAccessor.requireSessionId(), body, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request)));
} catch (InvalidTokenException e) {
// 过滤器校验后仍可能被并发选择/登出撤销,沿用认证接口的统一安全响应。
return unauthorized(request);
}
}
@Operation(summary = "企业注册", description = "使用 company-register 短信验证码创建企业账号和企业资料")
@PostMapping("/company/register")
@ -88,7 +106,7 @@ public class AuthController {
}
}
@Operation(summary = "Mock 登录", description = "使用手机号+验证码登录,返回 Access/Refresh Token 并创建服务端登录会话")
@Operation(summary = "手机号验证码登录", description = "新手机号创建待选身份统一账号;已有账号保持身份,返回 Access/Refresh Token")
@PostMapping("/login")
public ResponseEntity<ApiResponse<Map<String, Object>>> mockLogin(
@Valid @RequestBody LoginRequest body,
@ -240,7 +258,7 @@ public class AuthController {
return ResponseEntity.ok(ApiResponse.ok(authService.getCurrentUser(userId), requestIdAccessor.get(request)));
}
private ResponseEntity<ApiResponse<TokenPairResponse>> unauthorized(HttpServletRequest request) {
private <T> ResponseEntity<ApiResponse<T>> unauthorized(HttpServletRequest request) {
return ResponseEntity.status(401).body(
ApiResponse.unauthorized("invalid or expired token", requestIdAccessor.get(request)));
}

4
mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java

@ -122,6 +122,10 @@ public class UserRoleGuard {
if (!isActive(user)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
// 未选择身份不能通过矛盾的 user_roles 数据获得业务权限。
if (user.getRole() == User.Role.UNASSIGNED) {
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_REQUIRED);
}
if (allowedRoles == null || allowedRoles.length == 0) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}

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

@ -453,8 +453,8 @@ class CompanyResumeDownloadIntegrationTest {
}
}
}
assertThat(pathCount).as("密码登录与企业注册/入驻新增路径后应为 182 paths").isEqualTo(182);
assertThat(opCount).as("密码登录与企业注册/入驻新增 operation 后应为 220 operations").isEqualTo(220);
assertThat(pathCount).as("统一账号身份选择新增一个路径后应为 183 paths").isEqualTo(183);
assertThat(opCount).as("统一账号身份选择新增一个 operation 后应为 221 operations").isEqualTo(221);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse();

3
mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java

@ -104,7 +104,8 @@ class AuthServiceTest {
Map<String, Object> result = authService.mockLogin("13800000099", "123456", "login", null, null, null);
assertEquals(99L, result.get("userId"));
assertEquals(User.Role.STUDENT, result.get("role"));
assertEquals(User.Role.UNASSIGNED, result.get("role"));
assertEquals(true, result.get("identitySelectionRequired"));
}
@Test

49
mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/IdentityPolicyTest.java

@ -0,0 +1,49 @@
package com.mydreamjob.auth.identity;
import com.mydreamjob.auth.User;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.common.exception.ErrorHttpStatusRegistry;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.http.HttpStatus;
import java.util.Set;
import static org.assertj.core.api.Assertions.*;
class IdentityPolicyTest {
@ParameterizedTest @ValueSource(strings={"/profile/me","/profile/me/deletion","/companies/me",
"/companies/me/onboarding","/companies/me/verification","/applications","/notifications",
"/conversations","/messages","/offers","/interviews","/abilities/types","/assessments/types",
"/company/candidates","/admin/users","/auth/identity/select/extra"})
void businessGetIsDenied(String path) {
assertThat(new IdentityAccessFilter(null,null,null,null).allowed("GET",path)).isFalse();
}
@ParameterizedTest @ValueSource(strings={"/jobs","/jobs/search","/jobs/filters","/companies/42",
"/companies/42/jobs","/auth/me","/v3/api-docs","/health"})
void publicGetRemainsAccessible(String path) {
assertThat(new IdentityAccessFilter(null,null,null,null).allowed("GET",path)).isTrue();
}
@Test void exactPostAllowlistNeverAllowsBusinessOrAuthWildcard() {
var p=new IdentityAccessFilter(null,null,null,null);
assertThat(p.allowed("POST","/auth/identity/select")).isTrue();
assertThat(p.allowed("POST","/auth/password/set")).isTrue();
assertThat(p.allowed("POST","/auth/other")).isFalse();
assertThat(p.allowed("POST","/jobs")).isFalse();
assertThat(p.allowed("PUT","/auth/me")).isFalse();
}
@Test void unassignedCannotUseContradictoryBusinessRoles() {
User u=User.builder().role(User.Role.UNASSIGNED).roles(Set.of(User.Role.ADMIN,User.Role.COMPANY,User.Role.STUDENT)).build();
for(User.Role role:Set.of(User.Role.ADMIN,User.Role.COMPANY,User.Role.STUDENT)) {
assertThatThrownBy(() -> new UserRoleGuard(null).requireRole(u,role)).isInstanceOf(BusinessException.class)
.satisfies(e -> assertThat(((BusinessException)e).getErrorCodeInt()).isEqualTo(ErrorCode.IDENTITY_SELECTION_REQUIRED.getCode()));
}
}
@Test void newErrorMappingsAreExplicit() {
var r=new ErrorHttpStatusRegistry();
assertThat(r.resolve(ErrorCode.IDENTITY_SELECTION_REQUIRED)).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(r.resolve(ErrorCode.IDENTITY_SELECTION_CONFLICT)).isEqualTo(HttpStatus.CONFLICT);
assertThat(r.resolve(ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE)).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
}
}

205
mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/IdentitySelectionMySqlTest.java

@ -0,0 +1,205 @@
package com.mydreamjob.auth.identity;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.*;
import com.mydreamjob.auth.company.CompanyRegistrationRequest.CompanyDetails;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.util.AopTestUtils;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@Testcontainers(disabledWithoutDocker = false)
@SpringBootTest(properties = "app.company-registration.enabled=true")
@ActiveProfiles("mysql-it")
class IdentitySelectionMySqlTest {
@Container static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("identity_selection").withUsername("identity_test").withPassword("identity-test-only");
@DynamicPropertySource static void properties(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", MYSQL::getJdbcUrl);
r.add("spring.datasource.username", MYSQL::getUsername);
r.add("spring.datasource.password", MYSQL::getPassword);
}
@Autowired IdentitySelectionService service;
@SpyBean UserRepository users;
@SpyBean CompanyRepository companies;
@SpyBean AuthSessionService sessions;
@SpyBean AuditService audit;
@Autowired PlatformTransactionManager transactions;
@Autowired JdbcTemplate jdbc;
@MockBean RedisTemplate<String,Object> redis;
private final List<Long> owned = new ArrayList<>();
private final AuditContext context = AuditContext.builder().requestId("identity-mysql-test").build();
private record Fixture(Long id, AuthSessionService.SessionTokens tokens) { }
private Fixture fixture() {
return new TransactionTemplate(transactions).execute(tx -> {
String phone = "199000095"+String.format("%02d", owned.size()+1);
assertThat(users.findByPhone(phone)).isEmpty();
User u = users.saveAndFlush(User.builder().phone(phone).role(User.Role.UNASSIGNED)
.roles(new HashSet<>()).active(true).build());
owned.add(u.getId());
return new Fixture(u.getId(), sessions.createSession(u, Instant.now(), context));
});
}
private IdentitySelectionRequest request(String identity) {
var r = new IdentitySelectionRequest();
r.setIdentity(IdentitySelectionRequest.Identity.valueOf(identity));
if ("COMPANY".equals(identity)) {
var c = new CompanyDetails(); c.setName("身份竞争测试企业"); r.setCompany(c);
}
return r;
}
@AfterEach void cleanup() {
reset(users, companies, sessions, audit);
for (Long id : owned) {
jdbc.update("delete from auth_tokens where user_id=?", id);
jdbc.update("delete from account_deletions where user_id=?", id);
jdbc.update("delete from companies where owner_user_id=?", id);
jdbc.update("delete from user_roles where user_id=?", id);
jdbc.update("delete from users where id=?", id);
assertThat(jdbc.queryForObject("select count(*) from users where id=?",Long.class,id)).isZero();
}
// 审计 append-only,容器销毁后自然结束,不删除审计行。
}
@ParameterizedTest @ValueSource(strings={"STUDENT","COMPANY"})
void successfulSelectionRevokesAllOldSessionsAndIssuesCurrentRole(String role) {
Fixture f = fixture();
var response = service.select(f.id(), f.tokens().sessionId(), request(role), context);
assertThat(response.role()).isEqualTo(role);
assertThat(jdbc.queryForList("select role from user_roles where user_id=?",String.class,f.id())).containsExactly(role);
assertThat(jdbc.queryForObject("select role from users where id=?",String.class,f.id())).isEqualTo(role);
assertThat(sessions.isSessionActive(f.id(),f.tokens().sessionId(),Instant.now())).isFalse();
assertThat(jdbc.queryForObject("select count(*) from auth_tokens where user_id=? and revoked=0 and consumed_at is null",Long.class,f.id())).isEqualTo(1L);
}
@ParameterizedTest @ValueSource(strings={"role","company","revoke","session","audit"})
void anyFailureRollsBackRoleCompanyOldRevocationAndNewTokens(String stage) {
Fixture f = fixture();
Map<String,List<Map<String,Object>>> before = snapshot(f.id());
RuntimeException fault = new IllegalStateException("injected identity test failure");
switch(stage) {
case "role" -> doThrow(new DataAccessResourceFailureException("injected repository failure")).when(users).saveAndFlush(any(User.class));
case "company" -> doThrow(new DataAccessResourceFailureException("injected repository failure")).when(companies).saveAndFlush(any());
case "revoke" -> doAnswer(call -> {call.callRealMethod(); throw fault;}).when(sessions)
.revokeAllUserSessions(eq(f.id()),eq("IDENTITY_SELECTED"),any());
case "session" -> doThrow(fault).when(sessions).createSession(any(),any(),any());
// 只对真实事务代理内部的 spy 注入故障;装配阶段不调用 MANDATORY 代理。
case "audit" -> doThrow(fault).when((AuditService) AopTestUtils.getUltimateTargetObject(audit))
.logRequired(eq(f.id()),eq("IDENTITY_SELECTED"),any(),any(),any(),any(),any());
}
Throwable failure = catchThrowable(() -> service.select(f.id(),f.tokens().sessionId(),request("COMPANY"),context));
if (stage.equals("role") || stage.equals("company")) {
assertThat(failure).isInstanceOf(BusinessException.class);
assertThat(((BusinessException) failure).getErrorCodeInt()).isEqualTo(ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE.getCode());
assertThat(failure).hasMessage(ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE.getMessage());
} else {
assertThat(failure).isSameAs(fault);
}
reset(users,companies,sessions,audit);
assertThat(snapshot(f.id())).isEqualTo(before);
assertThat(sessions.isSessionActive(f.id(),f.tokens().sessionId(),Instant.now())).isTrue();
}
private Map<String,List<Map<String,Object>>> snapshot(Long id) {
Map<String,List<Map<String,Object>>> rows = new LinkedHashMap<>();
rows.put("users",jdbc.queryForList("select * from users where id=? order by id",id));
rows.put("roles",jdbc.queryForList("select * from user_roles where user_id=? order by role",id));
rows.put("companies",jdbc.queryForList("select * from companies where owner_user_id=? order by id",id));
rows.put("tokens",jdbc.queryForList("select * from auth_tokens where user_id=? order by id",id));
rows.put("notifications",jdbc.queryForList("select * from notifications order by id"));
return rows;
}
@ParameterizedTest @ValueSource(strings={"STUDENT","COMPANY"})
void twoConcurrentSelectionsProduceOnlyOneCommittedIdentity(String other) throws Exception {
Fixture f = fixture();
List<Object> results = parallel(
() -> service.select(f.id(),f.tokens().sessionId(),request("COMPANY"),context),
() -> service.select(f.id(),f.tokens().sessionId(),request(other),context));
assertThat(results.stream().filter(IdentitySelectionResponse.class::isInstance).count()).isEqualTo(1);
assertThat(results.stream().filter(InvalidTokenException.class::isInstance).count()).isEqualTo(1);
String role = jdbc.queryForObject("select role from users where id=?",String.class,f.id());
assertThat(jdbc.queryForList("select role from user_roles where user_id=?",String.class,f.id())).containsExactly(role);
assertThat(jdbc.queryForObject("select count(*) from companies where owner_user_id=?",Long.class,f.id()))
.isEqualTo("COMPANY".equals(role)?1L:0L);
assertThat(jdbc.queryForObject("select count(*) from auth_tokens where user_id=? and revoked=0",Long.class,f.id())).isEqualTo(1L);
}
@Test void refreshRaceCannotKeepPreSelectionSessionAlive() throws Exception {
Fixture f = fixture();
List<Object> results = parallel(
() -> service.select(f.id(),f.tokens().sessionId(),request("STUDENT"),context),
() -> sessions.refresh(f.tokens().refreshToken(),Instant.now(),context));
assertThat(results.get(0)).isInstanceOf(IdentitySelectionResponse.class);
assertThat(results.get(1)).matches(x -> x instanceof AuthSessionService.SessionTokens || x instanceof InvalidTokenException);
assertThat(sessions.isSessionActive(f.id(),f.tokens().sessionId(),Instant.now())).isFalse();
}
@Test void inactiveUserCannotSelectIdentity() {
Fixture f=fixture();
// 锁内重新检查 active,不能依赖请求进入前的 JWT 校验。
jdbc.update("update users set is_active=0 where id=?",f.id());
assertThatThrownBy(() -> service.select(f.id(),f.tokens().sessionId(),request("STUDENT"),context))
.isInstanceOf(InvalidTokenException.class);
assertThat(jdbc.queryForObject("select role from users where id=?",String.class,f.id())).isEqualTo("UNASSIGNED");
}
@ParameterizedTest @ValueSource(strings={"PENDING","COMPLETED"})
void deletingAccountCannotSelect(String status) {
Fixture f=fixture();
jdbc.update("insert into account_deletions (user_id,status,version,created_at,updated_at) values (?,?,0,now(),now())",f.id(),status);
var before=snapshot(f.id());
Throwable failure=catchThrowable(() -> service.select(f.id(),f.tokens().sessionId(),request("STUDENT"),context));
// 此夹具故意保留 active=true;注销行由身份 Service 自身门禁拒绝。
assertThat(failure).isInstanceOf(BusinessException.class).hasMessage(ErrorCode.IDENTITY_SELECTION_CONFLICT.getMessage());
assertThat(snapshot(f.id())).isEqualTo(before);
}
@Test void cancelledDeletionDoesNotPreventIdentitySelection() {
Fixture f=fixture();
jdbc.update("insert into account_deletions (user_id,status,version,created_at,updated_at) values (?, 'NONE',0,now(),now())",f.id());
assertThat(service.select(f.id(),f.tokens().sessionId(),request("STUDENT"),context).role()).isEqualTo("STUDENT");
}
@Test void contradictoryBusinessRolesFailClosed() {
Fixture f=fixture();
jdbc.update("insert into user_roles (user_id,role) values (?, 'ADMIN')",f.id());
var before=snapshot(f.id());
assertThatThrownBy(() -> service.select(f.id(),f.tokens().sessionId(),request("STUDENT"),context))
.isInstanceOf(BusinessException.class).hasMessage(ErrorCode.IDENTITY_SELECTION_CONFLICT.getMessage());
assertThat(snapshot(f.id())).isEqualTo(before);
}
private List<Object> parallel(Callable<Object> first, Callable<Object> second) throws Exception {
ExecutorService pool=Executors.newFixedThreadPool(2);
CountDownLatch ready=new CountDownLatch(2), start=new CountDownLatch(1);
try {
List<Future<Object>> futures = new ArrayList<>();
for (Callable<Object> action : List.of(first,second)) {
futures.add(pool.submit(() -> {ready.countDown(); assertThat(start.await(10,TimeUnit.SECONDS)).isTrue();
try{return action.call();}catch(RuntimeException e){return e;}}));
}
assertThat(ready.await(10,TimeUnit.SECONDS)).isTrue(); start.countDown();
return List.of(futures.get(0).get(30,TimeUnit.SECONDS),futures.get(1).get(30,TimeUnit.SECONDS));
} finally {start.countDown(); pool.shutdownNow();}
}
}

141
mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityIndependentBoundaryTest.java

@ -0,0 +1,141 @@
package com.mydreamjob.auth.identity;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.SessionAuthenticationDetails;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.security.CurrentUserAccessor;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/** 独立检查过滤器实际响应,不用生产白名单生成期望。 */
class UnifiedIdentityIndependentBoundaryTest {
private final UserRepository users = mock(UserRepository.class);
private final ObjectMapper mapper = new ObjectMapper().registerModule(
new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
private final IdentityAccessFilter filter = new IdentityAccessFilter(
users, new CurrentUserAccessor(), mapper, new RequestIdAccessor());
@AfterEach
void clearContext() {
SecurityContextHolder.clearContext();
}
private MockHttpServletRequest authenticatedRequest(String method, String uri) {
var request = new MockHttpServletRequest(method, "/api/v1" + uri);
request.setContextPath("/api/v1");
request.addHeader("X-Request-Id", "identity-independent-boundary");
// 故意设置 ADMIN authority,证明数据库待选身份仍优先于凭证角色标签。
var authentication = new UsernamePasswordAuthenticationToken("42", null,
List.of(new SimpleGrantedAuthority("ROLE_ADMIN")));
authentication.setDetails(new SessionAuthenticationDetails(request, "independent-session"));
SecurityContextHolder.getContext().setAuthentication(authentication);
return request;
}
private void pendingUser() {
when(users.findById(42L)).thenReturn(Optional.of(User.builder().id(42L)
.role(User.Role.UNASSIGNED).roles(Set.of(User.Role.ADMIN)).active(true).build()));
}
@Test
void databaseIdentityOverridesAdminAuthorityBeforeBusinessChain() throws Exception {
pendingUser();
var response = new MockHttpServletResponse();
var entered = new AtomicInteger();
filter.doFilter(authenticatedRequest("GET", "/admin/users"), response,
(request, result) -> entered.incrementAndGet());
assertThat(entered.get()).isZero();
assertThat(response.getStatus()).isEqualTo(403);
var json = mapper.readTree(response.getContentAsString());
assertThat(json.path("code").asInt()).isEqualTo(10401);
assertThat(json.path("requestId").asText()).isEqualTo("identity-independent-boundary");
assertThat(response.getContentAsString()).doesNotContain("Exception", "session", "jdbc:");
}
@Test
void databaseFailureStopsChainAndReturnsSafe503() throws Exception {
when(users.findById(42L)).thenThrow(new DataAccessResourceFailureException(
"private-database-diagnostic-marker"));
var response = new MockHttpServletResponse();
var entered = new AtomicInteger();
filter.doFilter(authenticatedRequest("POST", "/auth/identity/select"), response,
(request, result) -> entered.incrementAndGet());
assertThat(entered.get()).isZero();
assertThat(response.getStatus()).isEqualTo(503);
assertThat(mapper.readTree(response.getContentAsString()).path("code").asInt()).isEqualTo(10403);
assertThat(response.getContentAsString()).doesNotContain("private-database-diagnostic-marker",
"Exception", "jdbc:");
}
@Test
void allowedIdentitySelectionPassesExactlyOnceWithContextPath() throws Exception {
pendingUser();
var entered = new AtomicInteger();
filter.doFilter(authenticatedRequest("POST", "/auth/identity/select"),
new MockHttpServletResponse(), (request, result) -> entered.incrementAndGet());
assertThat(entered.get()).isEqualTo(1);
}
@Test
void wrongMethodAndUnknownAccountPathStayClosed() {
for (String method : List.of("GET", "HEAD", "PUT", "PATCH", "DELETE")) {
assertThat(filter.allowed(method, "/auth/identity/select")).as(method).isFalse();
}
assertThat(filter.allowed("POST", "/auth/identity/select/extra")).isFalse();
assertThat(filter.allowed("POST", "/auth/future-feature")).isFalse();
assertThat(filter.allowed("HEAD", "/companies/me/onboarding")).isFalse();
assertThat(filter.allowed("HEAD", "/jobs")).isTrue();
}
@Test
void selectedExistingCompanyIsNotBlockedByPendingIdentityFilter() throws Exception {
when(users.findById(42L)).thenReturn(Optional.of(User.builder().id(42L)
.role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)).active(true).build()));
var entered = new AtomicInteger();
filter.doFilter(authenticatedRequest("GET", "/companies/me/onboarding"),
new MockHttpServletResponse(), (request, result) -> entered.incrementAndGet());
assertThat(entered.get()).isEqualTo(1);
}
@Test
void anonymousRequestRemainsOwnedByExistingSecurityChain() throws Exception {
SecurityContextHolder.clearContext();
var entered = new AtomicInteger();
filter.doFilter(new MockHttpServletRequest("GET", "/admin/users"),
new MockHttpServletResponse(), (request, result) -> entered.incrementAndGet());
assertThat(entered.get()).isEqualTo(1);
verifyNoInteractions(users);
}
@Test
void topLevelAndNestedPrivilegeInjectionAreRejectedEvenWithPermissiveMapper() {
var permissive = new ObjectMapper().disable(
com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
for (String json : List.of(
"{\"identity\":\"STUDENT\",\"role\":\"ADMIN\"}",
"{\"identity\":\"STUDENT\",\"userId\":42}",
"{\"identity\":\"COMPANY\",\"company\":{\"name\":\"独立测试企业\",\"verified\":true}}")) {
assertThatThrownBy(() -> permissive.readValue(json, IdentitySelectionRequest.class))
.isInstanceOf(com.fasterxml.jackson.databind.JsonMappingException.class);
}
}
}

236
mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java

@ -0,0 +1,236 @@
package com.mydreamjob.auth.identity;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.lifecycle.Startables;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
import java.sql.*;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.LockSupport;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.*;
/** 手工验收入口:构建后用 exec:java 执行,启动最新 JAR 和三个隔离真实中间件。 */
public final class UnifiedIdentityJarAcceptanceProbe {
private static final ObjectMapper JSON=new ObjectMapper();
private static final HttpClient HTTP=HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
private static final String PASSWORD="IdentityProbe!2026#A";
private static int checks;
private static Process app;
private static int port;
private static Connection db;
public static void main(String[] args) throws Exception {
Path evidence=Path.of("../docs/acceptance/unified-identity");
Files.createDirectories(evidence);
try(var mysql=new MySQLContainer<>("mysql:8.4").withDatabaseName("identity_blackbox")
.withUsername("identity_probe").withPassword("identity-probe-container-only");
var redis=new GenericContainer<>("redis:7.4-alpine").withExposedPorts(6379);
var rabbit=new GenericContainer<>("rabbitmq:3.13-management-alpine").withExposedPorts(5672,15672)
.withEnv("RABBITMQ_DEFAULT_USER","identity_probe")
.withEnv("RABBITMQ_DEFAULT_PASS","identity-probe-container-only")) {
Startables.deepStart(Stream.of(mysql,redis,rabbit)).join();
try(Connection connection=DriverManager.getConnection(mysql.getJdbcUrl(),mysql.getUsername(),mysql.getPassword())) {
db=connection;
try {
start(mysql,redis,rabbit,false,evidence.resolve("jar-disabled.log"));
JsonNode company=login("19900009601");
call("POST","/auth/identity/select",company.path("accessToken").asText(),
"{\"identity\":\"COMPANY\",\"company\":{\"name\":\"统一身份黑盒测试企业\"}}",503);
assertThat(one("select role from users where phone=?","19900009601")).isEqualTo("UNASSIGNED"); checks++;
stop();
start(mysql,redis,rabbit,true,evidence.resolve("jar-enabled.log"));
JsonNode runtime=call("GET","/v3/api-docs",null,null,200);
JsonNode contract=JSON.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).isEqualTo(operations(contract)); checks++;
assertThat(runtime.path("paths").size()).isEqualTo(183); checks++;
assertThat(operations(runtime)).hasSize(221); checks++;
JsonNode pending=login("19900009602");
assertThat(pending.path("role").asText()).isEqualTo("UNASSIGNED"); checks++;
assertThat(pending.path("identitySelectionRequired").asBoolean()).isTrue(); checks++;
assertThat(one("select count(*) from user_roles where user_id=?",pending.path("userId").asLong())).isEqualTo(0L); checks++;
String pendingAccess=pending.path("accessToken").asText();
var before=snapshot();
for(String uri:List.of("/profile/me","/companies/me/onboarding","/notifications","/assessments/types","/company/candidates")) {
JsonNode denied=call("GET",uri,pendingAccess,null,403);
assertThat(denied.path("code").asInt()).isEqualTo(10401); checks++;
}
call("POST","/jobs",pendingAccess,"{}",403);
assertThat(snapshot()).isEqualTo(before); checks++;
call("GET","/jobs",pendingAccess,null,200);
assertThat(call("GET","/auth/me",pendingAccess,null,200).path("data").path("identitySelectionRequired").asBoolean()).isTrue(); checks++;
// 仅隔离验收库准备 BCrypt 夹具;不伪造真实短信服务商投递。
String hash=new BCryptPasswordEncoder().encode(PASSWORD);
update("update users set password_hash=?, password_changed_at=now() where id=?",hash,pending.path("userId").asLong());
JsonNode passwordLogin=call("POST","/auth/password/login",null,
JSON.writeValueAsString(Map.of("phone","19900009602","password",PASSWORD)),200).path("data");
assertThat(passwordLogin.path("identitySelectionRequired").asBoolean()).isTrue(); checks++;
JsonNode student=call("POST","/auth/identity/select",passwordLogin.path("accessToken").asText(),"{\"identity\":\"STUDENT\"}",200).path("data");
assertThat(student.path("role").asText()).isEqualTo("STUDENT"); checks++;
assertThat(JSON.readTree(Base64.getUrlDecoder().decode(student.path("accessToken").asText().split("\\.")[1])).path("role").asText()).isEqualTo("STUDENT"); checks++;
assertThat(one("select role from user_roles where user_id=?",pending.path("userId").asLong())).isEqualTo("STUDENT"); checks++;
for(JsonNode old:List.of(pending,passwordLogin)) {
call("GET","/auth/me",old.path("accessToken").asText(),null,401);
call("POST","/auth/refresh",null,JSON.writeValueAsString(Map.of("refreshToken",old.path("refreshToken").asText())),401);
}
call("GET","/auth/me",student.path("accessToken").asText(),null,200);
JsonNode repeated=call("POST","/auth/identity/select",student.path("accessToken").asText(),"{\"identity\":\"STUDENT\"}",200).path("data");
assertThat(repeated.has("accessToken")).isFalse(); checks++;
call("POST","/auth/identity/select",student.path("accessToken").asText(),
"{\"identity\":\"COMPANY\",\"company\":{\"name\":\"拒绝切换企业\"}}",409);
JsonNode selectedCompany=call("POST","/auth/identity/select",company.path("accessToken").asText(),
"{\"identity\":\"COMPANY\",\"company\":{\"name\":\"统一身份黑盒测试企业\"}}",200).path("data");
assertThat(selectedCompany.path("onboardingStatus").asText()).isEqualTo("UNVERIFIED"); checks++;
assertThat(one("select role from user_roles where user_id=?",company.path("userId").asLong())).isEqualTo("COMPANY"); checks++;
assertThat(one("select verified from companies where owner_user_id=?",company.path("userId").asLong())).isEqualTo(false); checks++;
assertThat(call("GET","/companies/me/onboarding",selectedCompany.path("accessToken").asText(),null,200)
.path("data").path("onboardingStatus").asText()).isEqualTo("UNVERIFIED"); checks++;
update("update users set password_hash=?, password_changed_at=now() where id=?",hash,company.path("userId").asLong());
JsonNode companyPasswordLogin=call("POST","/auth/password/login",null,
JSON.writeValueAsString(Map.of("phone","19900009601","password",PASSWORD)),200).path("data");
assertThat(companyPasswordLogin.path("role").asText()).isEqualTo("COMPANY"); checks++;
assertThat(companyPasswordLogin.path("identitySelectionRequired").asBoolean()).isFalse(); checks++;
assertThat(JSON.readTree(Base64.getUrlDecoder().decode(companyPasswordLogin.path("accessToken").asText().split("\\.")[1])).path("role").asText()).isEqualTo("COMPANY"); checks++;
call("GET","/company/candidates",selectedCompany.path("accessToken").asText(),null,403);
JsonNode race=login("19900009603");
concurrentSelect(race);
assertThat(one("select count(*) from user_roles where user_id=?",race.path("userId").asLong())).isEqualTo(1L); checks++;
assertThat(one("select count(*) from auth_tokens where user_id=? and revoked=0 and consumed_at is null",race.path("userId").asLong())).isEqualTo(1L); checks++;
String rabbitUrl="http://"+rabbit.getHost()+":"+rabbit.getMappedPort(15672)+"/api/queues";
String basic="Basic "+Base64.getEncoder().encodeToString("identity_probe:identity-probe-container-only".getBytes(java.nio.charset.StandardCharsets.UTF_8));
JsonNode queues=JSON.readTree(HTTP.send(HttpRequest.newBuilder(URI.create(rabbitUrl)).header("Authorization",basic).GET().build(),HttpResponse.BodyHandlers.ofString()).body());
assertThat(queues.size()).isEqualTo(4); checks++;
for(JsonNode q:queues) {
assertThat(q.has("messages")).isTrue();
assertThat(q.path("messages").asLong()).isZero();
assertThat(q.path("messages_ready").asLong()).isZero();
assertThat(q.path("messages_unacknowledged").asLong()).isZero(); checks++;
}
cleanup();
assertThat(one("select count(*) from users where phone in (?,?,?)","19900009601","19900009602","19900009603")).isEqualTo(0L); checks++;
stop();
for(String name:List.of("jar-disabled.log","jar-enabled.log")) {
String log=Files.readString(evidence.resolve(name));
assertThat(log).doesNotContain(PASSWORD,hash,pendingAccess,student.path("accessToken").asText(),
"19900009601","19900009602","19900009603"); checks++;
}
Files.writeString(Path.of("target/identity-probe-result.json"),JSON.writeValueAsString(Map.of(
"checks",checks,"failures",0,"mysql","8.4","redis","7.4","rabbitmq","3",
"paths",183,"operations",221,"smsDelivery","mock sender; not real provider",
"fixtureResidual",0,"auditPolicy","append-only; isolated container lifecycle")));
System.out.println("Unified identity latest-JAR acceptance PASS: "+checks+" checks; 183 paths/221 operations; fixture residual 0");
} finally {stop();}
}
}
}
private static void start(MySQLContainer<?> mysql,GenericContainer<?> redis,GenericContainer<?> rabbit,boolean enabled,Path log) throws Exception {
port=freePort(); int management=freePort();
var builder=new ProcessBuilder(Path.of(System.getProperty("java.home"),"bin","java.exe").toString(),
"-Dfile.encoding=UTF-8","-jar","target/mydreamjob-server-0.0.1-SNAPSHOT.jar","--server.port="+port,
"--server.address=127.0.0.1","--management.server.port="+management);
var env=builder.environment();
env.put("SPRING_PROFILES_ACTIVE",""); env.put("DB_URL",mysql.getJdbcUrl());
env.put("DB_USERNAME",mysql.getUsername()); env.put("DB_PASSWORD",mysql.getPassword());
env.put("REDIS_HOST",redis.getHost()); env.put("REDIS_PORT",redis.getMappedPort(6379).toString()); env.put("REDIS_PASSWORD","");
env.put("RABBITMQ_HOST",rabbit.getHost()); env.put("RABBITMQ_PORT",rabbit.getMappedPort(5672).toString());
env.put("RABBITMQ_USERNAME","identity_probe"); env.put("RABBITMQ_PASSWORD","identity-probe-container-only"); env.put("RABBITMQ_VHOST","/");
env.put("JWT_SECRET","identity-probe-legacy-key-test-only-0001");
env.put("JWT_ACCESS_SECRET","identity-probe-access-key-test-only-0001");
env.put("JWT_REFRESH_SECRET","identity-probe-refresh-key-test-only-0001");
env.put("JWT_TOKEN_HASH_SECRET","identity-probe-hash-key-test-only-0001");
env.put("DELETION_PHONE_HASH_SECRET","identity-probe-deletion-key-test-only-0001");
env.put("PASSWORD_LOGIN_ENABLED","true"); env.put("PASSWORD_RATE_KEY_SECRET","identity-probe-password-key-test-only-0001");
env.put("COMPANY_REGISTRATION_ENABLED",Boolean.toString(enabled)); env.put("ADMIN_BOOTSTRAP_ENABLED","false");
env.put("SMS_MOCK_ENABLED","true"); env.put("OFFER_SCHEDULER_ENABLED","false");
env.put("MATCH_NOTIFICATION_SCHEDULER_ENABLED","false"); env.put("SCORING_REFRESH_ENABLED","false");
env.put("SCORING_REFRESH_COMPENSATION_ENABLED","false");
env.put("RESUME_STORAGE_DIR","./target/identity-probe-storage/resumes");
env.put("RESUME_EXPORT_DIR","./target/identity-probe-storage/resume-exports");
env.put("DATA_EXPORT_DIR","./target/identity-probe-storage/data-exports");
app=builder.redirectErrorStream(true).redirectOutput(log.toFile()).start();
long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(90);
while(System.nanoTime()<deadline) {
assertThat(app.isAlive()).as("JAR must remain alive; inspect redacted local evidence").isTrue();
try {
if(HTTP.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:"+port+"/api/v1/health"))
.timeout(Duration.ofSeconds(2)).GET().build(),HttpResponse.BodyHandlers.discarding()).statusCode()==200) return;
} catch(java.io.IOException e) { /* 尚未监听,不视为验收成功。 */ }
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100));
}
throw new AssertionError("JAR readiness timeout");
}
private static int freePort() throws Exception {try(ServerSocket socket=new ServerSocket(0)){return socket.getLocalPort();}}
private static void stop() throws Exception {
if(app!=null) {app.destroy(); if(!app.waitFor(15,TimeUnit.SECONDS)){app.destroyForcibly();assertThat(app.waitFor(10,TimeUnit.SECONDS)).isTrue();} app=null;}
}
private static HttpResponse<String> send(String method,String uri,String token,String body) throws Exception {
var b=HttpRequest.newBuilder(URI.create("http://127.0.0.1:"+port+"/api/v1"+uri)).timeout(Duration.ofSeconds(15));
if(token!=null)b.header("Authorization","Bearer "+token);
if(body!=null)b.header("Content-Type","application/json");
return HTTP.send(b.method(method,body==null?HttpRequest.BodyPublishers.noBody():HttpRequest.BodyPublishers.ofString(body)).build(),HttpResponse.BodyHandlers.ofString());
}
private static JsonNode call(String method,String uri,String token,String body,int expected) throws Exception {
var response=send(method,uri,token,body);
assertThat(response.statusCode()).as("%s %s",method,uri).isEqualTo(expected); checks++;
JsonNode result=JSON.readTree(response.body());
if(expected>=400) assertThat(response.body()).doesNotContain("Exception","jdbc:","passwordHash","storagePath","at com.mydreamjob");
return result;
}
private static JsonNode login(String phone) throws Exception {
assertThat(one("select count(*) from users where phone=?",phone)).isEqualTo(0L);
call("POST","/auth/sms/send?phone="+phone+"&purpose=login",null,null,200);
String code=(String) one("select code from sms_verification_codes where phone=? and purpose='login' order by id desc limit 1",phone);
return call("POST","/auth/login",null,JSON.writeValueAsString(Map.of("phone",phone,"code",code,"purpose","login")),200).path("data");
}
private static Object one(String sql,Object...args) throws Exception {
try(var p=db.prepareStatement(sql)){for(int i=0;i<args.length;i++)p.setObject(i+1,args[i]);
try(var r=p.executeQuery()){assertThat(r.next()).isTrue();return r.getObject(1);}}
}
private static void update(String sql,Object...args) throws Exception {
try(var p=db.prepareStatement(sql)){for(int i=0;i<args.length;i++)p.setObject(i+1,args[i]);p.executeUpdate();}
}
private static Set<String> operations(JsonNode doc) {
Set<String> out=new TreeSet<>();doc.path("paths").fields().forEachRemaining(p ->
p.getValue().fieldNames().forEachRemaining(m -> out.add(m+" "+p.getKey())));return out;
}
private static Map<String,List<String>> snapshot() throws Exception {
Map<String,List<String>> result=new TreeMap<>();
for(String table:List.of("users","user_roles","companies","auth_tokens","notifications","messages","audit_logs",
"jobs","applications","offers","interviews","company_verifications","sms_verification_codes")) {
List<String> rows=new ArrayList<>();
try(var s=db.createStatement();var r=s.executeQuery("select * from "+table)) {
while(r.next()){List<String> columns=new ArrayList<>();for(int c=1;c<=r.getMetaData().getColumnCount();c++)columns.add(Objects.toString(r.getObject(c),"<null>"));rows.add(JSON.writeValueAsString(columns));}
}
Collections.sort(rows);result.put(table,rows);
}return result;
}
private static void concurrentSelect(JsonNode pending) throws Exception {
var executor=Executors.newFixedThreadPool(2);var ready=new CountDownLatch(2);var start=new CountDownLatch(1);
try {
List<Future<Integer>> requests=new ArrayList<>();
for(int i=0;i<2;i++) requests.add(executor.submit(() -> {ready.countDown();assertThat(start.await(10,TimeUnit.SECONDS)).isTrue();
return send("POST","/auth/identity/select",pending.path("accessToken").asText(),"{\"identity\":\"STUDENT\"}").statusCode();}));
assertThat(ready.await(10,TimeUnit.SECONDS)).isTrue();start.countDown();
assertThat(List.of(requests.get(0).get(30,TimeUnit.SECONDS),requests.get(1).get(30,TimeUnit.SECONDS))).containsExactlyInAnyOrder(200,401);checks++;
} finally {start.countDown();executor.shutdownNow();}
}
private static void cleanup() throws Exception {
try(var p=db.prepareStatement("select id,phone from users where phone in (?,?,?)")) {
for(int i=1;i<=3;i++)p.setString(i,"1990000960"+i);
List<Long> ids=new ArrayList<>();try(var r=p.executeQuery()){while(r.next())ids.add(r.getLong(1));}
for(Long id:ids){update("delete from auth_tokens where user_id=?",id);update("delete from companies where owner_user_id=?",id);update("delete from user_roles where user_id=?",id);update("delete from users where id=?",id);}
for(int i=1;i<=3;i++)update("delete from sms_verification_codes where phone=?","1990000960"+i);
}
}
private UnifiedIdentityJarAcceptanceProbe() { }
}

213
mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java

@ -0,0 +1,213 @@
package com.mydreamjob.auth.identity;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDateTime;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest(properties = {"app.company-registration.enabled=true",
"app.auth.password.enabled=true", "app.auth.password.rate-key-secret=identity-integration-test-rate-secret-only-0001",
"spring.datasource.url=jdbc:h2:mem:unified_identity;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"})
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
@Transactional
class UnifiedRegistrationIntegrationTest {
@Autowired MockMvc mvc;
@Autowired ObjectMapper mapper;
@Autowired SmsVerificationCodeRepository codes;
@Autowired UserRepository users;
@Autowired JdbcTemplate jdbc;
@Autowired org.springframework.security.crypto.password.PasswordEncoder passwords;
@Autowired com.mydreamjob.auth.company.CompanyRegistrationProperties registration;
@MockBean RedisTemplate<String, Object> redis;
@org.springframework.boot.test.mock.mockito.SpyBean IdentitySelectionService identitySelection;
private JsonNode login(String phone) throws Exception {
codes.saveAndFlush(SmsVerificationCode.builder().phone(phone).code("123456").purpose("login")
.attempts(0).maxAttempts(5).used(false).createdAt(LocalDateTime.now())
.expiredAt(LocalDateTime.now().plusMinutes(5)).build());
return mapper.readTree(mvc.perform(post("/auth/login").contentType(MediaType.APPLICATION_JSON)
.content("{\"phone\":\""+phone+"\",\"code\":\"123456\",\"purpose\":\"login\"}"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString()).path("data");
}
@Test void newAccountHasNoBusinessRole() throws Exception {
JsonNode data = login("19900009401");
assertThat(data.path("role").asText()).isEqualTo("UNASSIGNED");
assertThat(data.path("identitySelectionRequired").asBoolean()).isTrue();
assertThat(jdbc.queryForList("select role from user_roles where user_id=?", data.path("userId").asLong())).isEmpty();
}
@Test void sessionRevokedAfterFilterValidationReturnsSafe401() throws Exception {
String token=login("19900009411").path("accessToken").asText();
// 模拟通过过滤器后、取得用户锁前被并发请求撤销会话;真实并发另由 MySQL/JAR 覆盖。
org.mockito.Mockito.doThrow(new InvalidTokenException()).when(identitySelection).select(
org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.any(IdentitySelectionRequest.class),
org.mockito.ArgumentMatchers.any(com.mydreamjob.audit.AuditContext.class));
String body=mvc.perform(post("/auth/identity/select").header("Authorization","Bearer "+token)
.contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"STUDENT\"}"))
.andExpect(status().isUnauthorized()).andExpect(jsonPath("$.code").value(401))
.andReturn().getResponse().getContentAsString();
assertThat(body).doesNotContain("Exception","jdbc:","auth_tokens",token);
}
@Test void pendingIdentityCannotUseAuthenticatedOnlyBusinessEndpoints() throws Exception {
String token = login("19900009402").path("accessToken").asText();
for (String path : new String[]{"/assessments/types","/abilities/types","/notifications",
"/companies/me/onboarding","/profile/me","/messages","/admin/unknown"}) {
mvc.perform(get(path).header("Authorization", "Bearer "+token)).andExpect(status().isForbidden());
}
mvc.perform(get("/auth/me").header("Authorization", "Bearer "+token))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.identitySelectionRequired").value(true));
mvc.perform(get("/jobs").header("Authorization", "Bearer "+token)).andExpect(status().isOk());
}
@Test void studentSelectionRotatesCredentials() throws Exception {
JsonNode before = login("19900009403");
JsonNode selected = mapper.readTree(mvc.perform(post("/auth/identity/select")
.header("Authorization", "Bearer "+before.path("accessToken").asText())
.contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"STUDENT\"}"))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.role").value("STUDENT"))
.andReturn().getResponse().getContentAsString()).path("data");
mvc.perform(get("/auth/me").header("Authorization", "Bearer "+before.path("accessToken").asText()))
.andExpect(status().isUnauthorized());
mvc.perform(get("/auth/me").header("Authorization", "Bearer "+selected.path("accessToken").asText()))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.identitySelectionRequired").value(false));
mvc.perform(post("/auth/refresh").contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(java.util.Map.of("refreshToken", before.path("refreshToken").asText()))))
.andExpect(status().isUnauthorized());
}
@Test void companySelectionCreatesUnverifiedCompany() throws Exception {
JsonNode before = login("19900009404");
mvc.perform(post("/auth/identity/select").header("Authorization", "Bearer "+before.path("accessToken").asText())
.contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"COMPANY\",\"company\":{\"name\":\"身份选择测试企业\"}}"))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.role").value("COMPANY"))
.andExpect(jsonPath("$.data.onboardingStatus").value("UNVERIFIED"));
assertThat(jdbc.queryForList("select role from user_roles where user_id=?", String.class, before.path("userId").asLong()))
.containsExactly("COMPANY");
assertThat(jdbc.queryForObject("select verified from companies where owner_user_id=?", Boolean.class,
before.path("userId").asLong())).isFalse();
}
@Test void forbiddenClientFieldsAndAdminIdentityAreRejected() throws Exception {
String token = login("19900009405").path("accessToken").asText();
for (String body : new String[]{"{\"identity\":\"ADMIN\"}","{\"identity\":\"STUDENT\",\"userId\":1}",
"{\"identity\":\"COMPANY\"}","{\"identity\":\"STUDENT\",\"company\":{\"name\":\"bad\"}}",
"{\"identity\":\"COMPANY\",\"company\":{\"name\":\"bad\",\"verified\":true}}"}) {
mvc.perform(post("/auth/identity/select").header("Authorization", "Bearer "+token)
.contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isBadRequest());
}
}
@Test void existingStudentIsNotReset() throws Exception {
users.saveAndFlush(User.builder().phone("19900009406").role(User.Role.STUDENT)
.roles(Set.of(User.Role.STUDENT)).active(true).build());
JsonNode data = login("19900009406");
assertThat(data.path("role").asText()).isEqualTo("STUDENT");
assertThat(data.has("identitySelectionRequired")).isTrue();
assertThat(data.path("identitySelectionRequired").asBoolean()).isFalse();
}
@ParameterizedTest @ValueSource(strings={"COMPANY","ADMIN"})
void otherExistingRolesAreNeverReset(String role) throws Exception {
String phone=role.equals("COMPANY")?"19900009411":"19900009412";
User.Role existing=User.Role.valueOf(role);
users.saveAndFlush(User.builder().phone(phone).role(existing).roles(Set.of(existing)).active(true).build());
JsonNode data=login(phone);
assertThat(data.path("role").asText()).isEqualTo(role);
assertThat(data.path("identitySelectionRequired").asBoolean()).isFalse();
}
@Test void passwordLoginRetainsPendingState() throws Exception {
org.mockito.Mockito.when(redis.execute(org.mockito.ArgumentMatchers.any(org.springframework.data.redis.core.script.RedisScript.class),
org.mockito.ArgumentMatchers.anyList(),org.mockito.ArgumentMatchers.any(Object[].class))).thenReturn(1L);
users.saveAndFlush(User.builder().phone("19900009407").role(User.Role.UNASSIGNED).roles(Set.of())
.active(true).passwordHash(passwords.encode("IdentityTest!2026#A")).build());
mvc.perform(post("/auth/password/login").contentType(MediaType.APPLICATION_JSON)
.content("{\"phone\":\"19900009407\",\"password\":\"IdentityTest!2026#A\"}"))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.role").value("UNASSIGNED"))
.andExpect(jsonPath("$.data.identitySelectionRequired").value(true));
}
@Test void companySwitchOffDoesNotPreventStudentSelection() throws Exception {
JsonNode before=login("19900009408");
String previous=registration.getEnabled();
try {
registration.setEnabled("false");
mvc.perform(post("/auth/identity/select").header("Authorization","Bearer "+before.path("accessToken").asText())
.contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"COMPANY\",\"company\":{\"name\":\"测试企业\"}}"))
.andExpect(status().isServiceUnavailable());
assertThat(users.findById(before.path("userId").asLong()).orElseThrow().getRole()).isEqualTo(User.Role.UNASSIGNED);
mvc.perform(post("/auth/identity/select").header("Authorization","Bearer "+before.path("accessToken").asText())
.contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"STUDENT\"}"))
.andExpect(status().isOk());
} finally {registration.setEnabled(previous);}
}
@Test void repeatSelectionIsReadOnlyAndChangingIdentityConflicts() throws Exception {
JsonNode before=login("19900009409");
JsonNode result=mapper.readTree(mvc.perform(post("/auth/identity/select")
.header("Authorization","Bearer "+before.path("accessToken").asText())
.contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"STUDENT\"}"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString()).path("data");
var tokens=jdbc.queryForList("select * from auth_tokens where user_id=? order by id",before.path("userId").asLong());
var audits=jdbc.queryForList("select * from audit_logs order by id");
mvc.perform(post("/auth/identity/select").header("Authorization","Bearer "+result.path("accessToken").asText())
.contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"STUDENT\"}"))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.accessToken").doesNotExist())
.andExpect(jsonPath("$.data.reauthenticationRequired").value(true));
mvc.perform(post("/auth/identity/select").header("Authorization","Bearer "+result.path("accessToken").asText())
.contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"COMPANY\",\"company\":{\"name\":\"测试企业\"}}"))
.andExpect(status().isConflict());
assertThat(jdbc.queryForList("select * from auth_tokens where user_id=? order by id",before.path("userId").asLong())).isEqualTo(tokens);
assertThat(jdbc.queryForList("select * from audit_logs order by id")).isEqualTo(audits);
}
@Test void runtimeInventoryHasNoPendingIdentityBusinessBypass() throws Exception {
JsonNode doc=mapper.readTree(mvc.perform(get("/v3/api-docs")).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString());
// 生成产物只用于本地结构对账,不提交运行日志或令牌。
java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-openapi-runtime.json"),mapper.writeValueAsString(doc));
String token=login("19900009410").path("accessToken").asText();
// 独立列出已批准的公开契约,不调用生产 gate.allowed 生成期望值。
Set<String> publicGet=Set.of("/","/health","/health/details","/jobs","/jobs/search","/jobs/filters",
"/jobs/compare","/jobs/public/{jobId}","/jobs/{jobId}/reviews","/jobs/{jobId}/reviews/{reviewId}",
"/jobs/{jobId}/reviews/summary","/companies/{companyId}","/companies/{companyId}/jobs",
"/job-recommendation-taxonomies","/auth/me");
Set<String> accountPost=Set.of("/auth/identity/select","/auth/logout","/auth/password/set",
"/auth/password/change","/auth/login","/auth/password/login","/auth/password/reset",
"/auth/refresh","/auth/sms/send","/auth/company/register");
int denied=0;
var paths=doc.path("paths").fields();
while(paths.hasNext()) {
var path=paths.next();
String uri=path.getKey().replaceAll("\\{[^}]+}","1");
var methods=path.getValue().fieldNames();
while(methods.hasNext()) {
String method=methods.next().toUpperCase(java.util.Locale.ROOT);
if((method.equals("GET") && publicGet.contains(path.getKey()))
|| (method.equals("POST") && accountPost.contains(path.getKey()))) continue;
mvc.perform(request(org.springframework.http.HttpMethod.valueOf(method),uri)
.header("Authorization","Bearer "+token).contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(10401));
denied++;
}
}
assertThat(denied).isEqualTo(196);
java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-identity-gate-result.json"),
"{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}");
}
}

6
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java

@ -19,7 +19,7 @@ class GlobalExceptionHandlerStatusContractTest {
private static final Set<String> FORBIDDEN = Set.of(
"FORBIDDEN", "INTERVIEW_ACCESS_DENIED", "APPLICATION_OWNER_MISMATCH",
"MATCH_REQUIRED_FOR_CONVERSATION", "OFFER_COMPARE_NOT_OWNER", "COMPANY_NOT_VERIFIED");
"MATCH_REQUIRED_FOR_CONVERSATION", "OFFER_COMPARE_NOT_OWNER", "COMPANY_NOT_VERIFIED", "IDENTITY_SELECTION_REQUIRED");
private static final Set<String> NOT_FOUND = Set.of(
"COMPANY_NOT_FOUND", "PROFILE_NOT_FOUND", "JOB_NOT_FOUND", "APPLICATION_NOT_FOUND",
@ -65,7 +65,7 @@ class GlobalExceptionHandlerStatusContractTest {
"ABILITY_ATTEMPT_INVALID_STATE", "ABILITY_CONCURRENT_UPDATE", "ABILITY_DUPLICATE_ACTIVE",
"ABILITY_IDEMPOTENCY_CONFLICT", "ABILITY_RETAKE_COOLDOWN",
"RISK_RULE_CONCURRENT_UPDATE", "RISK_EVENT_CONCURRENT_UPDATE", "RISK_INVALID_ACTION",
"COMPANY_REGISTRATION_CONFLICT", "COMPANY_ONBOARDING_INCONSISTENT_STATE");
"COMPANY_REGISTRATION_CONFLICT", "COMPANY_ONBOARDING_INCONSISTENT_STATE", "IDENTITY_SELECTION_CONFLICT");
private static final Set<String> TOO_MANY_REQUESTS = Set.of(
"REPORT_RATE_LIMITED", "ABILITY_RETAKE_LIMIT", "TALENT_INVITATION_RATE_LIMITED",
@ -73,7 +73,7 @@ class GlobalExceptionHandlerStatusContractTest {
private static final Set<String> UNAUTHORIZED = Set.of("CREDENTIALS_INVALID");
private static final Set<String> SERVICE_UNAVAILABLE = Set.of(
"COMPANY_REGISTRATION_DISABLED", "COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE");
"COMPANY_REGISTRATION_DISABLED", "COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE", "IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE");
private final GlobalExceptionHandler handler = new GlobalExceptionHandler(new RequestIdAccessor(), new ErrorHttpStatusRegistry());

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

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

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

@ -98,6 +98,10 @@ class GlobalExceptionHandlerMappingParityTest {
private static HttpStatus baselineStatus(ErrorCode errorCode) {
String name = errorCode.name();
// 新功能追加映射与历史快照分离,不改历史错误码的期望值。
if (name.equals("IDENTITY_SELECTION_REQUIRED")) return HttpStatus.FORBIDDEN;
if (name.equals("IDENTITY_SELECTION_CONFLICT")) return HttpStatus.CONFLICT;
if (name.equals("IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE")) return HttpStatus.SERVICE_UNAVAILABLE;
if (FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN;
if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND;
if (CONFLICT.contains(name)) return HttpStatus.CONFLICT;
@ -114,8 +118,8 @@ class GlobalExceptionHandlerMappingParityTest {
@Test
@DisplayName("B2:显式映射集合与重构前快照逐项相等")
void explicitMappingSetEqualsPreRefactorSnapshot() {
assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(120);
assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(119);
assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(123);
assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(122);
for (ErrorCode errorCode : ErrorCode.values()) {
HttpStatus expected = baselineStatus(errorCode);

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

@ -42,6 +42,10 @@ class R01BIndependentHttpMappingParityTest {
"COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE", HttpStatus.SERVICE_UNAVAILABLE);
private static final Map<String, HttpStatus> PHASE_B_ADDITIONS =
Map.of("COMPANY_NOT_VERIFIED", HttpStatus.FORBIDDEN);
private static final Map<String, HttpStatus> IDENTITY_ADDITIONS = Map.of(
"IDENTITY_SELECTION_REQUIRED", HttpStatus.FORBIDDEN,
"IDENTITY_SELECTION_CONFLICT", HttpStatus.CONFLICT,
"IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE", HttpStatus.SERVICE_UNAVAILABLE);
private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry();
private final GlobalExceptionHandler handler =
@ -88,7 +92,7 @@ class R01BIndependentHttpMappingParityTest {
// HEAD 基线有 169 条;后续阶段新增码单独列出,不修改任何历史基线行。
assertThat(base).hasSize(169);
assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK");
assertThat(ErrorCode.values()).hasSize(174);
assertThat(ErrorCode.values()).hasSize(177);
int checked = 0;
for (ErrorCode code : ErrorCode.values()) {
@ -96,6 +100,7 @@ class R01BIndependentHttpMappingParityTest {
if (expected == null) {
HttpStatus addedStatus = PHASE_A_ADDITIONS.get(code.name());
if (addedStatus == null) addedStatus = PHASE_B_ADDITIONS.get(code.name());
if (addedStatus == null) addedStatus = IDENTITY_ADDITIONS.get(code.name());
assertThat(addedStatus).as("新增错误码必须逐项明确登记: %s", code.name()).isNotNull();
assertThat(registry.resolve(code)).isEqualTo(addedStatus);
checked++;
@ -108,7 +113,7 @@ class R01BIndependentHttpMappingParityTest {
.isEqualTo(expected[1]);
checked++;
}
assertThat(checked).isEqualTo(174);
assertThat(checked).isEqualTo(177);
}
@Test
@ -120,10 +125,10 @@ class R01BIndependentHttpMappingParityTest {
assertThat(explicitInBaseline).isEqualTo(115);
assertThat(base.size() - explicitInBaseline).isEqualTo(54);
assertThat(registry.explicitMappingCount()).isEqualTo(120);
assertThat(registry.explicitMappingCount()).isEqualTo(123);
assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54);
// 120 个显式常量覆盖 119 个唯一数字码(9103 共用)。
assertThat(registry.explicitNumericCodeCount()).isEqualTo(119);
// 123 个显式常量覆盖 122 个唯一数字码(9103 共用)。
assertThat(registry.explicitNumericCodeCount()).isEqualTo(122);
}
@Test
@ -235,9 +240,10 @@ class R01BIndependentHttpMappingParityTest {
// 真实注册表对每个枚举常量的解析与其声明位置无关:逐个比对基线即可证明
Map<String, int[]> base = baseline();
for (ErrorCode c : ErrorCode.values()) {
if (PHASE_A_ADDITIONS.containsKey(c.name()) || PHASE_B_ADDITIONS.containsKey(c.name())) {
if (PHASE_A_ADDITIONS.containsKey(c.name()) || PHASE_B_ADDITIONS.containsKey(c.name()) || IDENTITY_ADDITIONS.containsKey(c.name())) {
HttpStatus addedStatus = PHASE_A_ADDITIONS.get(c.name());
if (addedStatus == null) addedStatus = PHASE_B_ADDITIONS.get(c.name());
if (addedStatus == null) addedStatus = IDENTITY_ADDITIONS.get(c.name());
assertThat(registry.resolve(c)).isEqualTo(addedStatus);
} else {
assertThat(registry.resolve(c).value()).isEqualTo(base.get(c.name())[1]);

3
mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java

@ -20,7 +20,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@AutoConfigureMockMvc(addFilters = false)
@MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class,
AuthSessionService.class, java.time.Clock.class,
RequestIdAccessor.class, CurrentUserAccessor.class})
RequestIdAccessor.class, CurrentUserAccessor.class,
com.mydreamjob.auth.identity.IdentityAccessFilter.class})
class CompanyControllerMvcTest {
@Autowired

3
mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyOnboardingControllerTest.java

@ -22,7 +22,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@WebMvcTest(CompanyController.class)
@AutoConfigureMockMvc(addFilters = false)
@MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class, AuthSessionService.class,
java.time.Clock.class, RequestIdAccessor.class, CurrentUserAccessor.class})
java.time.Clock.class, RequestIdAccessor.class, CurrentUserAccessor.class,
com.mydreamjob.auth.identity.IdentityAccessFilter.class})
class CompanyOnboardingControllerTest {
@Autowired MockMvc mockMvc;
@MockBean CompanyService companyService;

Loading…
Cancel
Save