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": { "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": { "get": {
"tags": [ "tags": [
@ -1391,8 +1406,8 @@
"tags": [ "tags": [
"认证" "认证"
], ],
"summary": "Mock 登录", "summary": "手机号验证码登录",
"description": "使用手机号+验证码登录,返回 JWT token", "description": "手机号验证码登录;新手机号创建 UNASSIGNED 统一账号,identitySelectionRequired=true 时前端必须进入首次身份选择;旧角色保持不变。保留原字段及成功码 200。",
"operationId": "mockLogin", "operationId": "mockLogin",
"requestBody": { "requestBody": {
"content": { "content": {
@ -9484,6 +9499,38 @@
}, },
"components": { "components": {
"schemas": { "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": { "TaxonomyResponse": {
"type": "object", "type": "object",
"properties": { "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')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) print('paths:', len(d['paths']))
assert len(d['paths']) == 182, f"Expected 182 paths, got {len(d['paths'])}" assert len(d['paths']) == 183, f"Expected 183 paths, got {len(d['paths'])}"
paths = d['paths'] paths = d['paths']
@ -62,6 +62,7 @@ assert used_tags <= declared_tags, f'Undeclared operation tags: {sorted(used_tag
required = [ required = [
# Phase A 企业注册与入驻聚合(2 个 path / 2 个 operation) # Phase A 企业注册与入驻聚合(2 个 path / 2 个 operation)
'/auth/company/register', '/auth/company/register',
'/auth/identity/select',
'/companies/me/onboarding', '/companies/me/onboarding',
'/review-weight-templates', '/review-weight-templates',
'/profile/me/review-weights', '/profile/me/review-weights',
@ -647,7 +648,7 @@ assert _auth_paths == {
'/auth/sms/send', '/auth/login', '/auth/me', '/auth/sms/send', '/auth/login', '/auth/me',
'/auth/refresh', '/auth/logout', '/auth/refresh', '/auth/logout',
'/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset', '/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset',
'/auth/company/register', '/auth/company/register', '/auth/identity/select',
}, f'Unexpected /auth paths: {sorted(_auth_paths)}' }, f'Unexpected /auth paths: {sorted(_auth_paths)}'
print(' S02 refresh/logout and password authentication operations validated') print(' S02 refresh/logout and password authentication operations validated')
@ -705,7 +706,14 @@ print(' S03 company resume download contract validated; candidate DTOs remain r
# 统计 operation 总数并校验 V43 + N07 + S02 + S03 + Phase A 计数 220 # 统计 operation 总数并校验 V43 + N07 + S02 + S03 + Phase A 计数 220
operations = sum(len(methods) for methods in d['paths'].values()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) print('operations:', operations)
assert operations == 220, f"Expected 220 operations, got {operations}" assert operations == 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(" Offer advance endpoints all present")
print(" 26 operations all present") print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)") print(" Account deletion endpoints present (3 operations)")

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

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

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

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

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("phone", user.getPhone());
result.put("nickname", user.getNickname()); result.put("nickname", user.getNickname());
result.put("role", user.getRole()); result.put("role", user.getRole());
result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED);
result.put("accessToken", tokens.accessToken()); result.put("accessToken", tokens.accessToken());
result.put("refreshToken", tokens.refreshToken()); result.put("refreshToken", tokens.refreshToken());
result.put("expiresIn", AuthService.EXPIRES_IN_SECONDS); result.put("expiresIn", AuthService.EXPIRES_IN_SECONDS);

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_DISABLED(10301, "company registration is disabled"),
COMPANY_REGISTRATION_CONFLICT(10302, "company registration conflict"), COMPANY_REGISTRATION_CONFLICT(10302, "company registration conflict"),
COMPANY_ONBOARDING_INCONSISTENT_STATE(10303, "company onboarding state is inconsistent"), COMPANY_ONBOARDING_INCONSISTENT_STATE(10303, "company onboarding state is inconsistent"),
COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE(10304, "company registration dependency unavailable"); COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE(10304, "company registration dependency unavailable"),
IDENTITY_SELECTION_REQUIRED(10401, "identity selection is required"),
IDENTITY_SELECTION_CONFLICT(10402, "identity selection conflict"),
IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE(10403, "identity selection temporarily unavailable");
private final int code; private final int code;
private final String message; 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, .put(HttpStatus.FORBIDDEN,
ErrorCode.APPLICATION_OWNER_MISMATCH, ErrorCode.APPLICATION_OWNER_MISMATCH,
ErrorCode.COMPANY_NOT_VERIFIED, ErrorCode.COMPANY_NOT_VERIFIED,
ErrorCode.IDENTITY_SELECTION_REQUIRED,
ErrorCode.FORBIDDEN, ErrorCode.FORBIDDEN,
ErrorCode.INTERVIEW_ACCESS_DENIED, ErrorCode.INTERVIEW_ACCESS_DENIED,
ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION, ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION,
@ -146,6 +147,7 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.VERIFICATION_NOT_FOUND) ErrorCode.VERIFICATION_NOT_FOUND)
.put(HttpStatus.CONFLICT, .put(HttpStatus.CONFLICT,
ErrorCode.PASSWORD_STATE_CONFLICT, ErrorCode.PASSWORD_STATE_CONFLICT,
ErrorCode.IDENTITY_SELECTION_CONFLICT,
ErrorCode.ABILITY_ATTEMPT_INVALID_STATE, ErrorCode.ABILITY_ATTEMPT_INVALID_STATE,
ErrorCode.ABILITY_CONCURRENT_UPDATE, ErrorCode.ABILITY_CONCURRENT_UPDATE,
ErrorCode.ABILITY_DUPLICATE_ACTIVE, ErrorCode.ABILITY_DUPLICATE_ACTIVE,
@ -227,6 +229,7 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE) ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE)
.put(HttpStatus.SERVICE_UNAVAILABLE, .put(HttpStatus.SERVICE_UNAVAILABLE,
ErrorCode.COMPANY_REGISTRATION_DISABLED, ErrorCode.COMPANY_REGISTRATION_DISABLED,
ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE,
ErrorCode.COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE) ErrorCode.COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE)
.put(HttpStatus.TOO_MANY_REQUESTS, .put(HttpStatus.TOO_MANY_REQUESTS,
ErrorCode.PASSWORD_RATE_LIMITED, ErrorCode.PASSWORD_RATE_LIMITED,

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

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

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

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

@ -122,6 +122,10 @@ public class UserRoleGuard {
if (!isActive(user)) { if (!isActive(user)) {
throw new BusinessException(ErrorCode.FORBIDDEN); 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) { if (allowedRoles == null || allowedRoles.length == 0) {
throw new BusinessException(ErrorCode.FORBIDDEN); 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(pathCount).as("统一账号身份选择新增一个路径后应为 183 paths").isEqualTo(183);
assertThat(opCount).as("密码登录与企业注册/入驻新增 operation 后应为 220 operations").isEqualTo(220); assertThat(opCount).as("统一账号身份选择新增一个 operation 后应为 221 operations").isEqualTo(221);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); 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); Map<String, Object> result = authService.mockLogin("13800000099", "123456", "login", null, null, null);
assertEquals(99L, result.get("userId")); 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 @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( private static final Set<String> FORBIDDEN = Set.of(
"FORBIDDEN", "INTERVIEW_ACCESS_DENIED", "APPLICATION_OWNER_MISMATCH", "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( private static final Set<String> NOT_FOUND = Set.of(
"COMPANY_NOT_FOUND", "PROFILE_NOT_FOUND", "JOB_NOT_FOUND", "APPLICATION_NOT_FOUND", "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_ATTEMPT_INVALID_STATE", "ABILITY_CONCURRENT_UPDATE", "ABILITY_DUPLICATE_ACTIVE",
"ABILITY_IDEMPOTENCY_CONFLICT", "ABILITY_RETAKE_COOLDOWN", "ABILITY_IDEMPOTENCY_CONFLICT", "ABILITY_RETAKE_COOLDOWN",
"RISK_RULE_CONCURRENT_UPDATE", "RISK_EVENT_CONCURRENT_UPDATE", "RISK_INVALID_ACTION", "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( private static final Set<String> TOO_MANY_REQUESTS = Set.of(
"REPORT_RATE_LIMITED", "ABILITY_RETAKE_LIMIT", "TALENT_INVITATION_RATE_LIMITED", "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> UNAUTHORIZED = Set.of("CREDENTIALS_INVALID");
private static final Set<String> SERVICE_UNAVAILABLE = Set.of( 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()); 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 @Test
@DisplayName("Phase B:显式映射包含企业未认证拒绝状态") @DisplayName("Phase B:显式映射包含企业未认证拒绝状态")
void explicitMappingCountMatchesBaseline() { void explicitMappingCountMatchesBaseline() {
assertThat(registry.explicitMappingCount()).isEqualTo(120); assertThat(registry.explicitMappingCount()).isEqualTo(123);
// 120 个常量只覆盖 119 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与 // 123 个常量只覆盖 122 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与
// DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。 // DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。
assertThat(registry.explicitNumericCodeCount()).isEqualTo(119); assertThat(registry.explicitNumericCodeCount()).isEqualTo(122);
assertThat(registry.resolve(ErrorCode.COMPANY_NOT_VERIFIED)).isEqualTo(HttpStatus.FORBIDDEN); assertThat(registry.resolve(ErrorCode.COMPANY_NOT_VERIFIED)).isEqualTo(HttpStatus.FORBIDDEN);
} }
@ -123,12 +123,12 @@ class ErrorHttpStatusRegistryTest {
.isInstanceOf(UnsupportedOperationException.class); .isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN)) assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN))
.isInstanceOf(UnsupportedOperationException.class); .isInstanceOf(UnsupportedOperationException.class);
assertThat(view).hasSize(120); assertThat(view).hasSize(123);
Map<Integer, HttpStatus> numeric = ErrorHttpStatusRegistry.numericMappingsView(); Map<Integer, HttpStatus> numeric = ErrorHttpStatusRegistry.numericMappingsView();
assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK)) assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK))
.isInstanceOf(UnsupportedOperationException.class); .isInstanceOf(UnsupportedOperationException.class);
assertThat(numeric).hasSize(119); assertThat(numeric).hasSize(122);
} }
@Test @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) { private static HttpStatus baselineStatus(ErrorCode errorCode) {
String name = errorCode.name(); 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 (FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN;
if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND; if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND;
if (CONFLICT.contains(name)) return HttpStatus.CONFLICT; if (CONFLICT.contains(name)) return HttpStatus.CONFLICT;
@ -114,8 +118,8 @@ class GlobalExceptionHandlerMappingParityTest {
@Test @Test
@DisplayName("B2:显式映射集合与重构前快照逐项相等") @DisplayName("B2:显式映射集合与重构前快照逐项相等")
void explicitMappingSetEqualsPreRefactorSnapshot() { void explicitMappingSetEqualsPreRefactorSnapshot() {
assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(120); assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(123);
assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(119); assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(122);
for (ErrorCode errorCode : ErrorCode.values()) { for (ErrorCode errorCode : ErrorCode.values()) {
HttpStatus expected = baselineStatus(errorCode); 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); "COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE", HttpStatus.SERVICE_UNAVAILABLE);
private static final Map<String, HttpStatus> PHASE_B_ADDITIONS = private static final Map<String, HttpStatus> PHASE_B_ADDITIONS =
Map.of("COMPANY_NOT_VERIFIED", HttpStatus.FORBIDDEN); 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 ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry();
private final GlobalExceptionHandler handler = private final GlobalExceptionHandler handler =
@ -88,7 +92,7 @@ class R01BIndependentHttpMappingParityTest {
// HEAD 基线有 169 条;后续阶段新增码单独列出,不修改任何历史基线行。 // HEAD 基线有 169 条;后续阶段新增码单独列出,不修改任何历史基线行。
assertThat(base).hasSize(169); assertThat(base).hasSize(169);
assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK"); assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK");
assertThat(ErrorCode.values()).hasSize(174); assertThat(ErrorCode.values()).hasSize(177);
int checked = 0; int checked = 0;
for (ErrorCode code : ErrorCode.values()) { for (ErrorCode code : ErrorCode.values()) {
@ -96,6 +100,7 @@ class R01BIndependentHttpMappingParityTest {
if (expected == null) { if (expected == null) {
HttpStatus addedStatus = PHASE_A_ADDITIONS.get(code.name()); HttpStatus addedStatus = PHASE_A_ADDITIONS.get(code.name());
if (addedStatus == null) addedStatus = PHASE_B_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(addedStatus).as("新增错误码必须逐项明确登记: %s", code.name()).isNotNull();
assertThat(registry.resolve(code)).isEqualTo(addedStatus); assertThat(registry.resolve(code)).isEqualTo(addedStatus);
checked++; checked++;
@ -108,7 +113,7 @@ class R01BIndependentHttpMappingParityTest {
.isEqualTo(expected[1]); .isEqualTo(expected[1]);
checked++; checked++;
} }
assertThat(checked).isEqualTo(174); assertThat(checked).isEqualTo(177);
} }
@Test @Test
@ -120,10 +125,10 @@ class R01BIndependentHttpMappingParityTest {
assertThat(explicitInBaseline).isEqualTo(115); assertThat(explicitInBaseline).isEqualTo(115);
assertThat(base.size() - explicitInBaseline).isEqualTo(54); assertThat(base.size() - explicitInBaseline).isEqualTo(54);
assertThat(registry.explicitMappingCount()).isEqualTo(120); assertThat(registry.explicitMappingCount()).isEqualTo(123);
assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54); assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54);
// 120 个显式常量覆盖 119 个唯一数字码(9103 共用)。 // 123 个显式常量覆盖 122 个唯一数字码(9103 共用)。
assertThat(registry.explicitNumericCodeCount()).isEqualTo(119); assertThat(registry.explicitNumericCodeCount()).isEqualTo(122);
} }
@Test @Test
@ -235,9 +240,10 @@ class R01BIndependentHttpMappingParityTest {
// 真实注册表对每个枚举常量的解析与其声明位置无关:逐个比对基线即可证明 // 真实注册表对每个枚举常量的解析与其声明位置无关:逐个比对基线即可证明
Map<String, int[]> base = baseline(); Map<String, int[]> base = baseline();
for (ErrorCode c : ErrorCode.values()) { 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()); HttpStatus addedStatus = PHASE_A_ADDITIONS.get(c.name());
if (addedStatus == null) addedStatus = PHASE_B_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); assertThat(registry.resolve(c)).isEqualTo(addedStatus);
} else { } else {
assertThat(registry.resolve(c).value()).isEqualTo(base.get(c.name())[1]); 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) @AutoConfigureMockMvc(addFilters = false)
@MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class, @MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class,
AuthSessionService.class, java.time.Clock.class, AuthSessionService.class, java.time.Clock.class,
RequestIdAccessor.class, CurrentUserAccessor.class}) RequestIdAccessor.class, CurrentUserAccessor.class,
com.mydreamjob.auth.identity.IdentityAccessFilter.class})
class CompanyControllerMvcTest { class CompanyControllerMvcTest {
@Autowired @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) @WebMvcTest(CompanyController.class)
@AutoConfigureMockMvc(addFilters = false) @AutoConfigureMockMvc(addFilters = false)
@MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class, AuthSessionService.class, @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 { class CompanyOnboardingControllerTest {
@Autowired MockMvc mockMvc; @Autowired MockMvc mockMvc;
@MockBean CompanyService companyService; @MockBean CompanyService companyService;

Loading…
Cancel
Save