Browse Source

feat: add company registration and onboarding foundation

master
Zhang Shun 1 month ago
parent
commit
8c9429108c
  1. 268
      docs/openapi-rc.json
  2. 106
      docs/phase-a-company-registration-onboarding-implementation-report.md
  3. 157
      docs/phase-a-company-registration-onboarding-independent-verification.md
  4. 33
      docs/verify_openapi.py
  5. 23
      mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java
  6. 7
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java
  7. 14
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java
  8. 24
      mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationProperties.java
  9. 43
      mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java
  10. 11
      mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationResponse.java
  11. 104
      mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationService.java
  12. 8
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  13. 7
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java
  14. 8
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java
  15. 20
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingResponse.java
  16. 69
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingService.java
  17. 1
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  18. 13
      mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
  19. 2
      mydreamjob-server/src/main/resources/application.yml
  20. 4
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  21. 27
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsPurposeAllowlistTest.java
  22. 92
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationAtomicConsumptionTest.java
  23. 6
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java
  24. 6
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java
  25. 119
      mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationConcurrencyMySqlTest.java
  26. 55
      mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationFeatureFlagTest.java
  27. 133
      mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationIntegrationTest.java
  28. 76
      mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationSensitiveDataTest.java
  29. 144
      mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationServiceTest.java
  30. 145
      mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationTransactionRollbackTest.java
  31. 6
      mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java
  32. 13
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java
  33. 10
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java
  34. 31
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java
  35. 3
      mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java
  36. 46
      mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyOnboardingControllerTest.java
  37. 84
      mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyOnboardingServiceTest.java

268
docs/openapi-rc.json

@ -9426,6 +9426,60 @@
}
}
}
},
"/auth/company/register": {
"post": {
"tags": [
"认证"
],
"summary": "企业注册",
"description": "使用 company-register 短信验证码创建企业账号和企业资料",
"operationId": "companyRegister",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CompanyRegistrationRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseCompanyRegistrationResponse"
}
}
}
}
}
}
},
"/companies/me/onboarding": {
"get": {
"tags": [
"企业"
],
"summary": "查询企业入驻状态",
"description": "返回当前企业资料、认证状态和允许动作",
"operationId": "getMyOnboarding",
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseCompanyOnboardingResponse"
}
}
}
}
}
}
}
},
"components": {
@ -16056,6 +16110,220 @@
"format": "date-time"
}
}
},
"CompanyDetails": {
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 100,
"minLength": 0
},
"logoUrl": {
"type": "string",
"maxLength": 500,
"minLength": 0
},
"description": {
"type": "string"
},
"industry": {
"type": "string",
"maxLength": 80,
"minLength": 0
},
"website": {
"type": "string",
"maxLength": 255,
"minLength": 0
},
"address": {
"type": "string",
"maxLength": 255,
"minLength": 0
}
},
"required": [
"name"
]
},
"CompanyRegistrationRequest": {
"type": "object",
"properties": {
"phone": {
"type": "string",
"minLength": 1,
"pattern": "[0-9]{11}"
},
"code": {
"type": "string",
"minLength": 1,
"pattern": "[0-9]{6}"
},
"password": {
"type": "string",
"maxLength": 64,
"minLength": 12
},
"company": {
"$ref": "#/components/schemas/CompanyDetails"
}
},
"required": [
"code",
"company",
"password",
"phone"
]
},
"ApiResponseCompanyRegistrationResponse": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/CompanyRegistrationResponse"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"CompanyRegistrationResponse": {
"type": "object",
"properties": {
"userId": {
"type": "integer",
"format": "int64"
},
"companyId": {
"type": "integer",
"format": "int64"
},
"role": {
"type": "string"
},
"onboardingStatus": {
"type": "string"
},
"accessToken": {
"type": "string"
},
"refreshToken": {
"type": "string"
},
"expiresIn": {
"type": "integer",
"format": "int64"
}
}
},
"ApiResponseCompanyOnboardingResponse": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/CompanyOnboardingResponse"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"CompanyOnboardingResponse": {
"type": "object",
"properties": {
"company": {
"$ref": "#/components/schemas/CompanySummary"
},
"onboardingStatus": {
"type": "string"
},
"latestVerification": {
"$ref": "#/components/schemas/VerificationSummary"
},
"missingFields": {
"type": "array",
"items": {
"type": "string"
}
},
"allowedActions": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"CompanySummary": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"name": {
"type": "string"
},
"logoUrl": {
"type": "string"
},
"description": {
"type": "string"
},
"industry": {
"type": "string"
},
"website": {
"type": "string"
},
"address": {
"type": "string"
},
"verified": {
"type": "boolean"
}
}
},
"VerificationSummary": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"status": {
"type": "string"
},
"submittedAt": {
"type": "string",
"format": "date-time"
},
"decidedAt": {
"type": "string",
"format": "date-time"
}
}
}
},
"securitySchemes": {

106
docs/phase-a-company-registration-onboarding-implementation-report.md

@ -0,0 +1,106 @@
# Phase A 企业注册与入驻基础闭环实施报告
日期:2026-09-22
范围:企业专用注册、`company-register` 验证码、事务创建、入驻聚合查询
基线:`master` / `00153aa5d92d13e66d6cdc42ecd1fe903d6e666`
## 1. 结论与边界
本阶段实现了企业注册和企业入驻状态聚合,不进入 Phase B 已认证企业业务门禁,不进入 Phase C 管理员用户与角色管理,不修改 `users.version`、`user_management_actions`、岗位/简历/Offer/面试等业务门禁。
当前未暂存、未提交、未推送;V1–V46 未修改;未新增 V47;没有修改生产数据库的迁移脚本。
## 2. 权威库存
编码前核对了 `AuthController`/`AuthService`、`SmsVerificationService`/`SmsVerificationCodeRepository`、`PasswordPolicy`/`PasswordAuthenticationService`、`AuthSessionService.createSession`、注销手机号墓碑、`User`/`UserRepository`/`user_roles`、`Company`/`CompanyRepository`/`CompanyService`、企业认证服务与状态、`ErrorCode`/`ErrorHttpStatusRegistry`、`SecurityConfig` 以及 OpenAPI 基线。
实际短信用途以源码为准,保留 `login`、`password-set`、`password-reset`,新增并白名单化 `company-register`;任意其他非空用途均拒绝。OpenAPI 基线为 180 paths / 218 operations,本阶段变更为 182 paths / 220 operations。
## 3. 修改文件
生产代码新增:
- `auth/company/CompanyRegistrationProperties.java`
- `auth/company/CompanyRegistrationRequest.java`
- `auth/company/CompanyRegistrationResponse.java`
- `auth/company/CompanyRegistrationService.java`
- `company/CompanyOnboardingResponse.java`
- `company/CompanyOnboardingService.java`
生产代码修改:`AuthController`、`CompanyController`、`SecurityConfig`、`application.yml`、`SmsVerificationService`、`SmsVerificationCodeRepository`、`AuditService`、`ErrorCode`、`ErrorHttpStatusRegistry`。
测试新增/调整为 Phase A 行为锁定、既有短信/映射/OpenAPI 计数同步;未修改 V1–V46 或 `pom.xml`。
## 4. 企业注册契约
新增 `POST /auth/company/register`,使用强类型请求 DTO 和 Jakarta Validation。客户端只能提交手机号、验证码、密码及企业资料,不能提交 `userId`、`role`、`verified` 或 `passwordHash`。成功响应包含 `userId`、`companyId`、`role=COMPANY`、`onboardingStatus=UNVERIFIED`、现有格式的 Access/Refresh Token 和 `expiresIn`,不回显完整手机号、密码、验证码或密码摘要。
新增 `GET /companies/me/onboarding`,仅认证用户可访问,响应只包含企业安全摘要、聚合状态、认证申请安全摘要、`missingFields` 与 `allowedActions`,不返回 `materialsJson`、`decisionReason`、`reviewerId`、存储路径或其他敏感企业数据。未新增重复的 verifications 接口。
## 5. 事务边界
`CompanyRegistrationService.register` 在一个数据库事务中完成:功能开关、短信校验/消费、注销墓碑、手机号占用检查、BCrypt 密码、用户、`user_roles`、企业资料、登录会话和固定脱敏审计事件。用户保存并 flush 后再创建企业和会话;注册失败不会向客户端返回未提交 Token。数据库唯一约束是并发最终防线,`DataIntegrityViolationException` 转为统一冲突业务错误。
审计写入使用 `AuditService.logRequired` 的 `PROPAGATION_MANDATORY` + `saveAndFlush`,因此审计失败会使注册事务回滚。Redis 限流键不属于数据库事务,报告和测试没有虚假声称 Redis 自动回滚。
## 6. 验证码原子消费
`SmsVerificationCodeRepository.consumeIfValid` 使用条件更新,同时约束 `id`、`used=false`、未过期和 `attempts < maxAttempts`。只有受影响行数恰为 1 才算消费成功,0 行统一按验证码无效处理。错误验证码仍递增尝试次数并保留锁定语义;用途在查询前经过固定白名单校验。企业注册事务回滚时,数据库中的 `used` 更新随事务回滚。
修复前行为是“查询 → 修改实体 `used` → `save`”,受控变异到该实现时,`SmsVerificationAtomicConsumptionTest` 为 4 run / 2 failures(条件更新未调用、0 行竞争未失败);恢复条件更新后为 4 / 0 / 0 / 0。该变异证明测试不是空转。
## 7. 入驻状态推导
- `companies.verified=true` → `VERIFIED`;
- 未认证且无申请 → `UNVERIFIED`;
- 最新 `PENDING` 或 `RESUBMITTED` → `PENDING`;
- 最新 `REJECTED` → `REJECTED`;
- 未认证但最新 `APPROVED` 或未知状态 → 失败关闭并返回专用一致性错误。
`missingFields` 从资料实际内容派生,没有新增重复状态字段;`allowedActions` 随聚合状态返回。
## 8. 错误码与安全配置
新增唯一数字码:`COMPANY_REGISTRATION_DISABLED=10301`、`COMPANY_REGISTRATION_CONFLICT=10302`、`COMPANY_ONBOARDING_INCONSISTENT_STATE=10303`、`COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE=10304`。映射统一进入 `ErrorHttpStatusRegistry`:关闭/依赖不可用为 503,冲突/不一致为 409。
匿名放行仅新增 `POST /auth/company/register`;`GET /companies/me/onboarding` 保持认证,学生、管理员和匿名均不获得企业入驻数据。日志、审计和响应不记录密码、摘要、验证码、Token、SQL、手机号或请求 DTO。
## 9. 测试矩阵
新增了企业注册 service/integration/concurrency、入驻 service/controller、验证码原子消费、用途白名单、事务回滚、敏感数据、功能开关等测试类;并同步了既有短信安全、公司 Controller 和异常映射测试。
- Phase A 定向测试:全部通过;两项阻断整改后的完整定向集 **28/28**;
- 真实 MySQL/Testcontainers 并发:1/1,通过同验证码双线程仅一个成功;
- `mvn test`:**2865 / Failures 0 / Errors 0 / Skipped 0**;
- `mvn clean verify`:**2865 / Failures 0 / Errors 0 / Skipped 0**;
- OpenAPI:`python ..\\docs\\verify_openapi.py` → **182 paths / 220 operations,All checks passed**。
全量验证期间出现的 Hikari 重连和 MySQL 8.4/Flyway 兼容性 warning 均未导致失败;一次与编译并发运行的错误结果未计入,随后单独顺序运行得到上述完整通过结果。
测试/脚本自身问题:早期黑盒驱动曾把短信发送参数误放在 JSON body(实际接口是 query 参数),且临时 JWT 密钥长度不足 32 字符;两项均在产品代码之外修正。重复运行时还触发了短信限流,随后销毁临时数据库并以全新隔离容器重跑,得到本节所列成功结果。未放宽产品断言。
## 10. 真实 MySQL/Redis/RabbitMQ 黑盒
使用临时隔离容器(MySQL 8.4、Redis 7.4、RabbitMQ 3.13)和最新 JAR 完成真实 HTTP 验收。三中间件均真实健康,应用 `/api/v1/health` 为 200/UP。
验证结果:匿名 onboarding 为 401;无有效验证码注册被拒;发送 `company-register` 后注册 200;密码登录 200;`/auth/me` 200;带注册 Access Token 的 onboarding 200。数据库逐表核对为:目标手机号 `users=1`、`companies=1`、`user_roles=1` 且角色仅 `COMPANY`、`companies.verified=0`、验证码 `used=1`。密码登录产生第二个合法会话,属于预期行为。临时容器、临时 JAR 进程、临时端口和运行日志均已清理,未触碰既有 backend-infra 卷或历史数据。
## 11. 敏感信息扫描
扫描新增生产代码、测试代码、OpenAPI、验证脚本相关输出和本报告:未发现真实手机号、明文密码、BCrypt 摘要、JWT/Bearer、Refresh Token、数据库/Redis/RabbitMQ 密码、私钥、AWS key 或连接串。黑盒使用的临时凭据只存在于进程环境和临时容器,测试结束后容器和日志已删除;报告不记录其值。
## 12. 数据与兼容性
未新增数据库迁移;V1–V46 逐项保持不变。求职者短信登录、现有密码登录/设置/修改/重置、Token 刷新/登出、管理员初始化、既有企业资料和企业认证接口保持原路径与行为。企业注册后经现有 `/auth/password/login` 成功登录并得到 COMPANY 身份。
## 13. 未进入的范围
本轮没有实现已认证企业业务门禁、管理员用户/角色管理、`users.version`、`user_management_actions`、岗位发布、简历、Offer 或面试门禁改造;没有提交、推送或进入 Phase B/C。
## 14. 当前结束状态
验证完成后未暂存、未提交、未推送;无 Java/Maven 残留进程;8080/8081 空闲;临时测试账号与业务数据在隔离容器销毁时清理;未删除审计历史。工作区仍保留既有历史未跟踪文件和两个 Windows stat 缓存假阳性文件,未覆盖、删除或暂存。
## 15. 结论
实施阶段完成后,独立复验发现功能开关的宽松布尔绑定问题;现已整改为字符串配置并严格使用小写字面量 `true` 判断。事务回滚测试也已补齐 users、user_roles、companies、session、audit 五类独立故障注入,新增回滚覆盖后的完整 Phase A 定向集为 **28/28**,全量回归为 **2865/2865**,均无失败、错误或跳过。

157
docs/phase-a-company-registration-onboarding-independent-verification.md

@ -0,0 +1,157 @@
# Phase A 企业注册与入驻基础闭环独立代码复验报告
日期:2026-09-22
## 1. 复验范围与边界
本次复验针对 Phase A:企业专用注册、`company-register` 验证码用途、验证码原子消费、用户/角色/企业/会话事务、企业入驻聚合查询、测试与 OpenAPI。
未进入 Phase B 已认证企业业务门禁,也未进入 Phase C 管理员用户与角色管理;未修改生产代码、未暂存、未提交、未推送。
## 2. 前置状态
- 分支:`master`
- HEAD:`00153aaf5d92d13e66d6cdc42ecd1fe903d6e666`
- 暂存区:空
- 迁移:V1–V46 未修改,未新增 V47
- `pom.xml`:无修改
- 两个既有 Windows stat 缓存假阳性文件的工作区 blob 与 HEAD 相同,未触碰、未暂存
- 历史未跟踪验收文件和 `backend-infra` 文件均保留
## 3. 独立静态核对
通过项:
1. 注册服务使用事务,按 COMPANY 角色创建用户,并显式写入仅含 COMPANY 的 `user_roles`。
2. 用户、企业保存后 flush,再创建登录会话;企业初始 `verified=false`。
3. 注册审计使用固定脱敏文案,未发现密码、验证码、Token 或摘要输出路径。
4. 验证码消费使用条件更新,条件包含 id、`used=false`、未过期和 attempts 上限,受影响行数必须为 1。
5. 短信用途采用固定白名单,包含 `company-register`,不接受任意非空用途。
6. 匿名规则仅新增 `/auth/company/register`;`/companies/me/onboarding` 仍要求认证。
7. 入驻状态对无记录、PENDING/RESUBMITTED、REJECTED、VERIFIED 和 APPROVED 不一致状态分别处理;不一致状态失败关闭。
8. DTO 未暴露 `userId`、客户端 `role`、`verified`、`passwordHash` 等不应由客户端控制的字段。
9. OpenAPI 中新增注册和入驻查询契约,未改变 V1–V46 或既有业务路径。
## 4. 定向测试与真实黑盒
实施测试与复验前已执行的 Phase A 定向测试为 **23/23,Failures=0,Errors=0,Skipped=0**,其中包含真实 MySQL/Testcontainers 并发注册测试。
此前全量结果:
- `mvn test`:2860/0/0/0
- `mvn clean verify`:2860/0/0/0
- OpenAPI:182 paths / 220 operations,All checks passed
隔离 MySQL 8.4、Redis 7.4、RabbitMQ 3.13 和最新 JAR 的黑盒结果:健康检查通过;发送 `company-register` 验证码后注册成功;密码登录成功;`/auth/me` 返回 COMPANY;入驻查询返回 200;目标数据为 1 个 COMPANY 用户、1 个 COMPANY 角色、1 个企业且 `verified=0`,验证码已消费。临时容器、进程、端口和日志已清理。
## 5. 阻断项:功能开关绑定不满足严格字面量要求
生产文件:`mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationProperties.java`
当前字段为:
```java
private boolean enabled = false;
```
Spring Boot 标准布尔绑定会把 `TRUE`、`True`、`yes`、`1`、`on` 均转换为 `true`。独立临时测试使用 `ApplicationContextRunner` 真实绑定配置,逐一设置:
```text
app.company-registration.enabled=TRUE
app.company-registration.enabled=True
app.company-registration.enabled=yes
app.company-registration.enabled=1
app.company-registration.enabled=on
```
复验结果:五个非字面量值均绑定为 enabled=true,测试失败:
```text
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
Expecting empty but was: ["TRUE", "True", "yes", "1", "on"]
```
这与设计要求“只有字面量 `true` 才开启,其他值不得误开启”不一致,因此 Phase A 独立复验判定 **FAIL**。临时测试已删除,未留在工作区。
整改建议:将配置原始值改为 String,并通过唯一方法严格判断:
```java
private String enabled = "false";
public boolean isEnabled() {
return "true".equals(enabled);
}
```
同时增加真实 Spring 属性绑定测试,覆盖未设置、`false`、`true`、`TRUE`、`True`、`yes`、`1`、`on`;只有小写字面量 `true` 应开启。
## 6. 整改前的次要测试覆盖缺口
以下是首次独立复验时发现的证据缺口,不作为已证明的生产缺陷:
1. `CompanyRegistrationTransactionRollbackTest` 目前主要覆盖审计失败,未分别注入并断言 users 保存、user_roles 保存、companies 保存、会话创建和审计写入五类失败;对 user_roles/auth_tokens 的零残留断言也不完整。
2. 注册请求中客户端附带未知的 role/verified/passwordHash 字段的既有测试,可能因验证码未准备而返回 400,不能单独证明未知字段被拒绝;DTO 本身未声明这些字段。
3. tombstone、Redis/数据库/会话依赖异常和每种冲突角色的完整 HTTP 黑盒覆盖仍需单独补齐或明确证据。
## 7. 复验结论
Phase A 的主要注册、验证码原子消费、事务、并发、入驻聚合和真实中间件路径基本可用,但严格功能开关绑定存在阻断缺陷;另有事务回滚测试覆盖不足。
最终判定:**FAIL,暂不能进入独立通过或提交检查点。**
本次独立复验未修改生产代码、未暂存、未提交、未推送,未进入 Phase B/C。
## 8. 阻断整改复验(2026-09-22)
针对第 5 节阻断,已按最小范围修改:
- `CompanyRegistrationProperties.enabled` 改为字符串,默认值为 `"false"`;
- `isEnabled()` 仅返回 `"true".equals(enabled)`;
- `CompanyRegistrationFeatureFlagTest` 增加真实 `ApplicationContextRunner` 绑定测试,覆盖 `TRUE`、`True`、`yes`、`1`、`on`、`false` 和 `true`。
整改后验证:
- 功能开关及关联测试:**9/9,F=0、E=0、S=0**;
- Phase A 定向测试:**24/24,F=0、E=0、S=0**;
- `mvn clean verify`:**2861/2861,F=0、E=0、S=0**;
- OpenAPI:**182 paths / 220 operations,All checks passed**。
独立绑定测试确认只有字面量 `true` 开启,五种非字面量值均保持关闭。未新增迁移,未进入 Phase B/C,未提交或推送。
功能开关阻断已清除;第 6 节的事务回滚覆盖缺口已在第 9 节补齐。
说明:第 7 节“FAIL”是整改前复验结论;当前状态以第 8、9 节整改复验结果为准。
## 9. 事务回滚覆盖整改复验(2026-09-22)
`CompanyRegistrationTransactionRollbackTest` 已补齐五类独立故障注入:
- users 保存失败;
- user_roles 真实插入触发器失败(H2 真实 SQL);
- companies 保存失败;
- 会话创建失败;
- 审计写入失败。
每种场景均验证 users、user_roles、companies、auth_tokens 无残留,验证码 `used=false`,并按精确测试手机号清理。测试结果:**5/5,F=0、E=0、S=0**。
## 10. 阻断修复后的完整回归收口(2026-09-22)
为避免把整改前的历史数字误读为当前结果,最终收口结果单独列示如下:
- 功能开关整改后的独立定向:9/9;
- 五类事务回滚注入:5/5;
- Phase A 完整定向测试:**28/28,F=0、E=0、S=0**;
- `mvn clean verify`:**2865/2865,F=0、E=0、S=0,BUILD SUCCESS**;
- OpenAPI:**182 paths / 220 operations,All checks passed**;
- 真实 MySQL/Testcontainers 并发注册:已执行并通过;
- V1–V46 未修改,未新增 V47;未进入 Phase B/C,仍未暂存、未提交、未推送。
期间 H2 user_roles 触发器用于证明真实 SQL 插入失败,触发日志中的预期 SQL 错误属于测试注入证据,不是生产异常;未修改生产错误映射,也未放宽任何断言。
## 11. 复验重跑收口(2026-09-22)
- 独立静态门禁通过:功能开关严格按小写字面量 `true` 开启;匿名放行仅包含 `/auth/company/register`;注册 DTO 未暴露 `userId`/`role`/`verified`/`passwordHash`;验证码白名单、条件消费和 `attempts < maxAttempts` 均已分别在 Service 与 Repository 位置核对;未发现 V47。
- 首次静态脚本曾把 `attempts < c.maxAttempts` 错查在 `SmsVerificationService.java`,造成脚本假阴性;修正为检查 `SmsVerificationCodeRepository.java` 后门禁通过。该问题仅存在于复验脚本,不是产品缺陷。
- Phase A 定向重跑:**28/28,Failures=0、Errors=0、Skipped=0**;真实 `CompanyRegistrationConcurrencyMySqlTest` 启动 MySQL 8.4 Testcontainers 并实际执行。
- OpenAPI:**182 paths / 220 operations,All checks passed**。
- 收尾:无 Java/Maven 残留进程,8080/8081 无监听,暂存区为空;本轮未修改生产代码、未提交、未推送,未进入 Phase B/C。

33
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths']))
assert len(d['paths']) == 180, f"Expected 180 paths, got {len(d['paths'])}"
assert len(d['paths']) == 182, f"Expected 182 paths, got {len(d['paths'])}"
paths = d['paths']
@ -60,6 +60,9 @@ declared_tags = {tag['name'] for tag in d.get('tags', [])}
assert used_tags <= declared_tags, f'Undeclared operation tags: {sorted(used_tags - declared_tags)}'
required = [
# Phase A 企业注册与入驻聚合(2 个 path / 2 个 operation)
'/auth/company/register',
'/companies/me/onboarding',
'/review-weight-templates',
'/profile/me/review-weights',
'/profile/me/privacy',
@ -644,9 +647,33 @@ assert _auth_paths == {
'/auth/sms/send', '/auth/login', '/auth/me',
'/auth/refresh', '/auth/logout',
'/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset',
'/auth/company/register',
}, f'Unexpected /auth paths: {sorted(_auth_paths)}'
print(' S02 refresh/logout and password authentication operations validated')
# ── Phase A:企业注册与入驻聚合 ─────────────────────────────────────
assert set(paths['/auth/company/register']) == {'post'}
_company_register = paths['/auth/company/register']['post']
assert _company_register['requestBody']['content']['application/json']['schema']['$ref'] \
== '#/components/schemas/CompanyRegistrationRequest'
assert _company_register['responses']['200']['content']['*/*']['schema']['$ref'] \
== '#/components/schemas/ApiResponseCompanyRegistrationResponse'
_company_req = resolve_schema(d['components']['schemas']['CompanyRegistrationRequest'])
assert sorted(_company_req['properties']) == ['code', 'company', 'password', 'phone']
for _forbidden in ('userId', 'role', 'verified', 'passwordHash'):
assert _forbidden not in json.dumps(_company_req, ensure_ascii=False)
assert set(paths['/companies/me/onboarding']) == {'get'}
assert paths['/companies/me/onboarding']['get']['responses']['200']['content']['*/*']['schema']['$ref'] \
== '#/components/schemas/ApiResponseCompanyOnboardingResponse'
for _schema_name in ('CompanyOnboardingResponse', 'CompanySummary', 'VerificationSummary'):
assert _schema_name in d['components']['schemas'], f'Missing Phase A schema: {_schema_name}'
_onboarding_text = json.dumps({k: d['components']['schemas'][k] for k in
('CompanyOnboardingResponse', 'CompanySummary', 'VerificationSummary')},
ensure_ascii=False)
for _forbidden in ('materialsJson', 'decisionReason', 'reviewerId', 'storagePath'):
assert _forbidden not in _onboarding_text
print(' Phase A company registration/onboarding paths and redacted schemas validated')
# ── S03:唯一新增的企业简历下载接口 ────────────────────────────────
_download_path = '/company/candidates/{applicationId}/resume'
assert _download_path in paths, 'Missing company resume download endpoint'
@ -675,10 +702,10 @@ for _p in paths:
assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}'
print(' S03 company resume download contract validated; candidate DTOs remain redacted')
# 统计 operation 总数并校验 V43 + N07 + S02 + S03 + password authentication 计数 218
# 统计 operation 总数并校验 V43 + N07 + S02 + S03 + Phase A 计数 220
operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations)
assert operations == 218, f"Expected 218 operations, got {operations}"
assert operations == 220, f"Expected 220 operations, got {operations}"
print(" Offer advance endpoints all present")
print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)")

23
mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java

@ -3,6 +3,8 @@ package com.mydreamjob.audit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@ -45,6 +47,27 @@ public class AuditService {
logWithContext(userId, action, entityType, entityId, details, context, capturedAt);
}
/**
* 必须与调用方业务事务一起成功的审计写入。仅用于需要原子审计的事务,
* 不吞异常;普通历史调用继续使用 log() 的非阻断语义。
*/
@Transactional(propagation = Propagation.MANDATORY)
public void logRequired(Long userId, String action, String entityType, Long entityId,
String details, AuditContext context, LocalDateTime capturedAt) {
AuditLog auditLog = AuditLog.builder()
.userId(userId)
.action(action)
.entityType(entityType)
.entityId(entityId)
.details(sanitize(details))
.ipAddress(context != null ? context.getIpAddress() : null)
.userAgent(context != null ? context.getUserAgent() : null)
.requestId(context != null ? context.getRequestId() : null)
.createdAt(capturedAt)
.build();
auditLogRepository.saveAndFlush(auditLog);
}
private void logWithContext(Long userId, String action, String entityType, Long entityId,
String details, AuditContext ctx, LocalDateTime createdAt) {
try {

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

@ -1,6 +1,7 @@
package com.mydreamjob.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@ -12,6 +13,12 @@ import java.util.Optional;
@Repository
public interface SmsVerificationCodeRepository extends JpaRepository<SmsVerificationCode, Long> {
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE SmsVerificationCode c SET c.used = true " +
"WHERE c.id = :id AND c.used = false AND c.expiredAt > :now " +
"AND c.attempts < c.maxAttempts")
int consumeIfValid(@Param("id") Long id, @Param("now") LocalDateTime now);
List<SmsVerificationCode> findByPhoneAndCreatedAtAfter(String phone, LocalDateTime after);
/**

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

@ -15,6 +15,7 @@ import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.concurrent.TimeUnit;
@ -51,6 +52,8 @@ public class SmsVerificationService {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final Pattern PHONE_PATTERN = Pattern.compile("[0-9]{11}");
private static final Pattern CODE_PATTERN = Pattern.compile("[0-9]{6}");
private static final Set<String> ALLOWED_PURPOSES = Set.of(
"login", "password-set", "password-reset", "company-register");
/**
* 发送验证码。
@ -160,9 +163,10 @@ public class SmsVerificationService {
throw new BusinessException(ErrorCode.SMS_CODE_INVALID);
}
// 原子性标记已使用并保存
smsCode.setUsed(true);
codeRepository.save(smsCode);
// 条件更新保证同一验证码并发只能被一个事务消费;更新 0 行表示已被其他请求消费。
if (codeRepository.consumeIfValid(smsCode.getId(), now) != 1) {
throw new BusinessException(ErrorCode.SMS_CODE_INVALID);
}
return true;
}
@ -189,9 +193,9 @@ public class SmsVerificationService {
}
private void validatePurpose(String purpose) {
if (purpose == null || purpose.isBlank()) {
if (purpose == null || !ALLOWED_PURPOSES.contains(purpose)) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"purpose is required");
"unsupported sms purpose");
}
}
}

24
mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationProperties.java

@ -0,0 +1,24 @@
package com.mydreamjob.auth.company;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/** Feature switch for the public company registration entry point. */
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "app.company-registration")
public class CompanyRegistrationProperties {
private String enabled = "false";
/**
* Enables registration only for the exact lower-case literal "true".
* Spring's default Boolean converter also accepts values such as "yes" and
* "1", which must not enable this safety-sensitive feature switch.
*/
public boolean isEnabled() {
return "true".equals(enabled);
}
}

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

@ -0,0 +1,43 @@
package com.mydreamjob.auth.company;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class CompanyRegistrationRequest {
@NotBlank
@Pattern(regexp = "[0-9]{11}")
private String phone;
@NotBlank
@Pattern(regexp = "[0-9]{6}")
private String code;
@NotBlank
@Size(min = 12, max = 64)
private String password;
@Valid
@NotNull
private CompanyDetails company;
@Data
public static class CompanyDetails {
@NotBlank
@Size(max = 100)
private String name;
@Size(max = 500)
private String logoUrl;
private String description;
@Size(max = 80)
private String industry;
@Size(max = 255)
private String website;
@Size(max = 255)
private String address;
}
}

11
mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationResponse.java

@ -0,0 +1,11 @@
package com.mydreamjob.auth.company;
public record CompanyRegistrationResponse(
Long userId,
Long companyId,
String role,
String onboardingStatus,
String accessToken,
String refreshToken,
long expiresIn) {
}

104
mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationService.java

@ -0,0 +1,104 @@
package com.mydreamjob.auth.company;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.AuthService;
import com.mydreamjob.auth.AuthSessionService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.auth.SmsVerificationService;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.deletion.AccountDeletionPhoneHasher;
import com.mydreamjob.deletion.AccountDeletionRepository;
import com.mydreamjob.auth.password.PasswordPolicy;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.DataAccessException;
import org.springframework.security.crypto.password.PasswordEncoder;
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.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class CompanyRegistrationService {
private static final String PURPOSE = "company-register";
private final CompanyRegistrationProperties properties;
private final SmsVerificationService smsVerificationService;
private final UserRepository userRepository;
private final CompanyRepository companyRepository;
private final PasswordPolicy passwordPolicy;
private final PasswordEncoder passwordEncoder;
private final AccountDeletionRepository accountDeletionRepository;
private final AccountDeletionPhoneHasher accountDeletionPhoneHasher;
private final AuthSessionService authSessionService;
private final AuditService auditService;
private final Clock clock;
@Transactional
public CompanyRegistrationResponse register(CompanyRegistrationRequest request, AuditContext ctx) {
if (!properties.isEnabled()) {
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_DISABLED);
}
String phone = request.getPhone().trim();
try {
smsVerificationService.verifyCode(phone, request.getCode(), PURPOSE);
String phoneHash = accountDeletionPhoneHasher.hash(phone);
if (accountDeletionRepository.findByDeletedPhoneHash(phoneHash).isPresent()) {
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_CONFLICT);
}
passwordPolicy.validateNewPassword(request.getPassword(), phone, null, passwordEncoder);
if (userRepository.findByPhoneForUpdate(phone).isPresent()) {
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_CONFLICT);
}
User user = User.builder()
.phone(phone)
.nickname("企业_" + phone.substring(7))
.passwordHash(passwordEncoder.encode(passwordPolicy.normalize(request.getPassword())))
.passwordChangedAt(LocalDateTime.ofInstant(clock.instant(), AuthSessionService.BUSINESS_ZONE))
.role(User.Role.COMPANY)
.roles(Set.of(User.Role.COMPANY))
.active(true)
.verified(false)
.build();
user = userRepository.saveAndFlush(user);
CompanyRegistrationRequest.CompanyDetails d = request.getCompany();
Company company = Company.builder()
.ownerUserId(user.getId())
.name(d.getName())
.logoUrl(d.getLogoUrl())
.description(d.getDescription())
.industry(d.getIndustry())
.website(d.getWebsite())
.address(d.getAddress())
.verified(false)
.build();
company = companyRepository.saveAndFlush(company);
Instant now = clock.instant();
AuthSessionService.SessionTokens tokens = authSessionService.createSession(user, now, ctx);
auditService.logRequired(user.getId(), "COMPANY_REGISTERED", "COMPANY", company.getId(),
"{\"result\":\"SUCCESS\"}", ctx,
LocalDateTime.ofInstant(now, AuthSessionService.BUSINESS_ZONE));
return new CompanyRegistrationResponse(user.getId(), company.getId(), User.Role.COMPANY.name(),
"UNVERIFIED", tokens.accessToken(), tokens.refreshToken(), AuthService.EXPIRES_IN_SECONDS);
} catch (DataIntegrityViolationException e) {
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_CONFLICT);
} catch (DataAccessException e) {
// 数据库、Redis 或会话持久化故障统一失败关闭,不把 SQL/连接信息返回给客户端。
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE);
}
}
}

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

@ -233,7 +233,13 @@ public enum ErrorCode {
TALENT_INVITATION_CONCURRENT_UPDATE(10206, "talent invitation was modified concurrently"),
TALENT_INVITATION_IDEMPOTENCY_CONFLICT(10207, "talent invitation idempotency conflict"),
TALENT_INVITATION_CONTENT_INVALID(10208, "invalid talent invitation content"),
TALENT_INVITATION_RATE_LIMITED(10209, "talent invitation rate limited");
TALENT_INVITATION_RATE_LIMITED(10209, "talent invitation rate limited"),
// 企业注册与入驻 Phase A 103xx
COMPANY_REGISTRATION_DISABLED(10301, "company registration is disabled"),
COMPANY_REGISTRATION_CONFLICT(10302, "company registration conflict"),
COMPANY_ONBOARDING_INCONSISTENT_STATE(10303, "company onboarding state is inconsistent"),
COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE(10304, "company registration dependency unavailable");
private final int code;
private final String message;

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

@ -221,7 +221,12 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.TALENT_INVITATION_IDEMPOTENCY_CONFLICT,
ErrorCode.TALENT_INVITATION_INVALID_STATE,
ErrorCode.VERIFICATION_ALREADY_APPROVED,
ErrorCode.VERIFICATION_ALREADY_PENDING)
ErrorCode.VERIFICATION_ALREADY_PENDING,
ErrorCode.COMPANY_REGISTRATION_CONFLICT,
ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE)
.put(HttpStatus.SERVICE_UNAVAILABLE,
ErrorCode.COMPANY_REGISTRATION_DISABLED,
ErrorCode.COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE)
.put(HttpStatus.TOO_MANY_REQUESTS,
ErrorCode.PASSWORD_RATE_LIMITED,
ErrorCode.ABILITY_RETAKE_LIMIT,

8
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java

@ -29,6 +29,14 @@ public class CompanyController {
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final CompanyService companyService;
private final CompanyOnboardingService companyOnboardingService;
@Operation(summary = "查询企业入驻状态", description = "返回当前企业资料、认证状态和允许动作")
@GetMapping("/me/onboarding")
public ResponseEntity<ApiResponse<CompanyOnboardingResponse>> getMyOnboarding(HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(companyOnboardingService.get(userId), requestIdAccessor.get(request)));
}
// ── 创建 ──────────────────────────────────────────────────

20
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingResponse.java

@ -0,0 +1,20 @@
package com.mydreamjob.company;
import java.time.LocalDateTime;
import java.util.List;
public record CompanyOnboardingResponse(
CompanySummary company,
String onboardingStatus,
VerificationSummary latestVerification,
List<String> missingFields,
List<String> allowedActions) {
public record CompanySummary(Long id, String name, String logoUrl, String description,
String industry, String website, String address, Boolean verified) {
}
public record VerificationSummary(Long id, String status, LocalDateTime submittedAt,
LocalDateTime decidedAt) {
}
}

69
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingService.java

@ -0,0 +1,69 @@
package com.mydreamjob.company;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.verification.CompanyVerification;
import com.mydreamjob.verification.CompanyVerificationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Service
@RequiredArgsConstructor
public class CompanyOnboardingService {
private final UserRoleGuard userRoleGuard;
private final CompanyRepository companyRepository;
private final CompanyVerificationRepository verificationRepository;
@Transactional(readOnly = true)
public CompanyOnboardingResponse get(Long userId) {
userRoleGuard.requireCompany(userId);
Company company = companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
CompanyVerification latest = verificationRepository.findTopByCompanyIdOrderByIdDesc(company.getId())
.orElse(null);
String status;
if (Boolean.TRUE.equals(company.getVerified())) {
status = "VERIFIED";
} else if (latest == null) {
status = "UNVERIFIED";
} else if ("PENDING".equals(latest.getStatus()) || "RESUBMITTED".equals(latest.getStatus())) {
status = "PENDING";
} else if ("REJECTED".equals(latest.getStatus())) {
status = "REJECTED";
} else if ("APPROVED".equals(latest.getStatus())) {
throw new BusinessException(ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE);
} else {
throw new BusinessException(ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE);
}
List<String> missing = new ArrayList<>();
if (isBlank(company.getName())) missing.add("name");
if (isBlank(company.getIndustry())) missing.add("industry");
if (isBlank(company.getDescription())) missing.add("description");
if (isBlank(company.getAddress())) missing.add("address");
List<String> actions = switch (status) {
case "UNVERIFIED", "REJECTED" -> List.of("UPDATE_PROFILE", "SUBMIT_VERIFICATION");
case "PENDING" -> List.of("UPDATE_PROFILE", "WAIT_REVIEW");
case "VERIFIED" -> List.of("ENTER_COMPANY_WORKSPACE");
default -> List.of();
};
CompanyOnboardingResponse.VerificationSummary verification = latest == null ? null
: new CompanyOnboardingResponse.VerificationSummary(latest.getId(), latest.getStatus(),
latest.getSubmittedAt(), latest.getDecidedAt());
CompanyOnboardingResponse.CompanySummary summary = new CompanyOnboardingResponse.CompanySummary(
company.getId(), company.getName(), company.getLogoUrl(), company.getDescription(),
company.getIndustry(), company.getWebsite(), company.getAddress(), company.getVerified());
return new CompanyOnboardingResponse(summary, status, verification, missing, actions);
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}

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

@ -79,6 +79,7 @@ public class SecurityConfig {
.requestMatchers("/auth/sms/send").permitAll()
.requestMatchers("/auth/login").permitAll()
.requestMatchers("/auth/password/login").permitAll()
.requestMatchers("/auth/company/register").permitAll()
.requestMatchers("/auth/password/reset").permitAll()
.requestMatchers("/auth/register").permitAll()
// S02:刷新允许匿名进入 Controller;是否成功仍取决于 Refresh Token 校验。

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

@ -13,6 +13,10 @@ import com.mydreamjob.auth.dto.PasswordOperationResponse;
import com.mydreamjob.auth.dto.PasswordResetRequest;
import com.mydreamjob.auth.dto.PasswordSetRequest;
import com.mydreamjob.auth.password.PasswordAuthenticationService;
import com.mydreamjob.auth.company.CompanyRegistrationRequest;
import com.mydreamjob.auth.company.CompanyRegistrationResponse;
import com.mydreamjob.auth.company.CompanyRegistrationService;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
@ -50,6 +54,15 @@ public class AuthController {
private final SmsVerificationService smsVerificationService;
private final CurrentUserAccessor currentUserAccessor;
private final PasswordAuthenticationService passwordAuthenticationService;
private final CompanyRegistrationService companyRegistrationService;
@Operation(summary = "企业注册", description = "使用 company-register 短信验证码创建企业账号和企业资料")
@PostMapping("/company/register")
public ResponseEntity<ApiResponse<CompanyRegistrationResponse>> companyRegister(
@Valid @RequestBody CompanyRegistrationRequest body, HttpServletRequest request) {
CompanyRegistrationResponse response = companyRegistrationService.register(body, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request)));
}
@Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。")
@PostMapping("/sms/send")

2
mydreamjob-server/src/main/resources/application.yml

@ -77,6 +77,8 @@ spring:
# S02:Access 与 Refresh 使用独立签名密钥,另设独立的 Token 摘要密钥。
# 三项密钥均无默认值,缺失即启动失败(见 AppJwtProperties 校验)。
app:
company-registration:
enabled: ${COMPANY_REGISTRATION_ENABLED:false}
jwt:
# 旧配置迁移项:S02 签发/校验的 Token **不使用**该密钥,不得回退到它。
# 仍保留是因为 talentdiscovery.CandidateKeyGenerator 用它派生候选人展示键;

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

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

27
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsPurposeAllowlistTest.java

@ -0,0 +1,27 @@
package com.mydreamjob.auth;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.sms.SmsCodeSender;
import com.mydreamjob.common.exception.BusinessException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.RedisTemplate;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ExtendWith(MockitoExtension.class)
class SmsPurposeAllowlistTest {
@Mock SmsVerificationCodeRepository codes;
@Mock RedisTemplate<String, Object> redis;
@Mock AuditService audit;
@Mock SmsCodeSender sender;
@Test
void rejectsArbitraryPurposeBeforeWritingCode() {
SmsVerificationService service = new SmsVerificationService(codes, redis, audit, sender);
assertThatThrownBy(() -> service.sendCode("19900000112", "arbitrary-purpose", null, null, null))
.isInstanceOf(BusinessException.class);
}
}

92
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationAtomicConsumptionTest.java

@ -0,0 +1,92 @@
package com.mydreamjob.auth;
import com.mydreamjob.auth.sms.SmsCodeSender;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.time.LocalDateTime;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class SmsVerificationAtomicConsumptionTest {
@Mock SmsVerificationCodeRepository codes;
@Mock RedisTemplate<String, Object> redis;
@Mock ValueOperations<String, Object> values;
@Mock AuditService audit;
@Mock SmsCodeSender sender;
@InjectMocks SmsVerificationService service;
@BeforeEach
void noLockout() {
lenient().when(redis.hasKey(anyString())).thenReturn(false);
lenient().when(redis.opsForValue()).thenReturn(values);
}
@Test
void successfulCodeUsesConditionalUpdateAndNeverEntitySave() {
SmsVerificationCode code = code(false, 0, 5);
when(codes.streamLatestUnusedByPhoneAndPurpose("19900000201", "company-register"))
.thenReturn(Stream.of(code));
when(codes.consumeIfValid(eq(7L), any())).thenReturn(1);
code.setId(7L);
assertTrue(service.verifyCode("19900000201", "123456", "company-register"));
verify(codes).consumeIfValid(eq(7L), any());
verify(codes, never()).save(any(SmsVerificationCode.class));
}
@Test
void zeroAffectedRowsIsAConsumedOrInvalidCode() {
SmsVerificationCode code = code(false, 0, 5);
code.setId(8L);
when(codes.streamLatestUnusedByPhoneAndPurpose("19900000202", "company-register"))
.thenReturn(Stream.of(code));
when(codes.consumeIfValid(eq(8L), any())).thenReturn(0);
assertThatThrownBy(() -> service.verifyCode("19900000202", "123456", "company-register"))
.isInstanceOf(BusinessException.class)
.extracting("errorCodeInt").isEqualTo(ErrorCode.SMS_CODE_INVALID.getCode());
verify(codes, never()).save(any(SmsVerificationCode.class));
}
@Test
void unsupportedPurposeIsRejectedBeforeAnyReadOrUpdate() {
assertThatThrownBy(() -> service.verifyCode("19900000203", "123456", "arbitrary"))
.isInstanceOf(BusinessException.class)
.extracting("errorCodeInt").isEqualTo(ErrorCode.BAD_REQUEST.getCode());
verifyNoInteractions(codes);
}
@Test
void attemptsAtLimitAreNotConsumedAgain() {
SmsVerificationCode code = code(false, 5, 5);
code.setId(9L);
when(codes.streamLatestUnusedByPhoneAndPurpose("19900000204", "company-register"))
.thenReturn(Stream.of(code));
assertThatThrownBy(() -> service.verifyCode("19900000204", "123456", "company-register"))
.isInstanceOf(BusinessException.class)
.extracting("errorCodeInt").isEqualTo(ErrorCode.SMS_CODE_ATTEMPTS_EXCEEDED.getCode());
verify(codes, never()).consumeIfValid(anyLong(), any());
}
private SmsVerificationCode code(boolean used, int attempts, int maxAttempts) {
return SmsVerificationCode.builder().id(7L).phone("19900000201").code("123456")
.purpose("company-register").used(used).attempts(attempts).maxAttempts(maxAttempts)
.createdAt(LocalDateTime.now()).expiredAt(LocalDateTime.now().plusMinutes(5)).build();
}
}

6
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java

@ -252,10 +252,14 @@ class SmsVerificationSecurityTest {
when(redisTemplate.hasKey(anyString())).thenReturn(false);
when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE))
.thenReturn(Stream.of(stored));
when(codeRepository.consumeIfValid(any(), any())).thenAnswer(invocation -> {
stored.setUsed(true);
return 1;
});
assertThat(service.verifyCode(SENSITIVE_PHONE, "654321", PURPOSE)).isTrue();
assertThat(stored.getUsed()).isTrue();
verify(codeRepository).save(stored);
verify(codeRepository).consumeIfValid(any(), any());
// 重放:已使用记录不再被查询命中
when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE))

6
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java

@ -74,10 +74,10 @@ class SmsVerificationServiceTest {
when(redisTemplate.hasKey(anyString())).thenReturn(false);
when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login"))
.thenReturn(java.util.stream.Stream.of(code));
when(codeRepository.consumeIfValid(any(), any())).thenReturn(1);
assertDoesNotThrow(() -> smsVerificationService.verifyCode("13800000001", "123456", "login"));
assertTrue(code.getUsed());
verify(codeRepository, times(1)).save(code);
verify(codeRepository, times(1)).consumeIfValid(any(), any());
}
@Test
@ -87,6 +87,7 @@ class SmsVerificationServiceTest {
// 第一次验证通过后 used=true
when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login"))
.thenReturn(java.util.stream.Stream.of(code));
when(codeRepository.consumeIfValid(any(), any())).thenReturn(1);
// 第一次成功
smsVerificationService.verifyCode("13800000001", "123456", "login");
@ -141,6 +142,7 @@ class SmsVerificationServiceTest {
// repository 按 createdAt DESC 返回,第一个是最新的
when(codeRepository.streamLatestUnusedByPhoneAndPurpose("13800000001", "login"))
.thenReturn(java.util.stream.Stream.of(newCode));
when(codeRepository.consumeIfValid(any(), any())).thenReturn(1);
assertDoesNotThrow(() -> smsVerificationService.verifyCode("13800000001", "123456", "login"));
// 旧验证码 999999 不应被验证通过

119
mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationConcurrencyMySqlTest.java

@ -0,0 +1,119 @@
package com.mydreamjob.auth.company;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.auth.*;
import com.mydreamjob.company.CompanyRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
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.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.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
@Testcontainers(disabledWithoutDocker = false)
@SpringBootTest
@ActiveProfiles("mysql-it")
class CompanyRegistrationConcurrencyMySqlTest {
private static final String PHONE = "19900000321";
private static final String CODE = "123456";
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("company_registration_concurrency")
.withUsername("company_registration_test")
.withPassword("company-registration-test");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
registry.add("app.company-registration.enabled", () -> "true");
}
@Autowired CompanyRegistrationService service;
@Autowired SmsVerificationCodeRepository codes;
@Autowired UserRepository users;
@Autowired CompanyRepository companies;
@Autowired JdbcTemplate jdbc;
@MockBean RedisTemplate<String, Object> redis;
@AfterEach
void cleanup() {
jdbc.update("DELETE FROM auth_tokens WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE);
jdbc.update("DELETE FROM companies WHERE owner_user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE);
jdbc.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE);
jdbc.update("DELETE FROM users WHERE phone = ?", PHONE);
jdbc.update("DELETE FROM sms_verification_codes WHERE phone = ?", PHONE);
}
@Test
void oneCodeCanCreateOnlyOneCompanyIdentityWithoutTimingSleeps() throws Exception {
when(redis.hasKey(anyString())).thenReturn(false);
codes.saveAndFlush(SmsVerificationCode.builder().phone(PHONE).purpose("company-register")
.code(CODE).attempts(0).maxAttempts(5).used(false)
.createdAt(LocalDateTime.now()).expiredAt(LocalDateTime.now().plusMinutes(5)).build());
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
List<Future<Object>> futures = new ArrayList<>();
for (int i = 0; i < 2; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
assertThat(start.await(10, TimeUnit.SECONDS)).isTrue();
try {
return service.register(request(), AuditContext.builder().build());
} catch (Throwable error) {
return error;
}
}));
}
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
Object first = futures.get(0).get(30, TimeUnit.SECONDS);
Object second = futures.get(1).get(30, TimeUnit.SECONDS);
pool.shutdownNow();
long successes = List.of(first, second).stream().filter(CompanyRegistrationResponse.class::isInstance).count();
assertThat(successes).isEqualTo(1);
assertThat(users.findAll().stream().filter(u -> PHONE.equals(u.getPhone())).count()).isEqualTo(1);
Long userId = users.findByPhone(PHONE).orElseThrow().getId();
assertThat(jdbc.queryForList("SELECT role FROM user_roles WHERE user_id = ?", String.class, userId))
.containsExactly("COMPANY");
assertThat(companies.findByOwnerUserId(userId)).isPresent();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM auth_tokens WHERE user_id = ?", Long.class, userId))
.isEqualTo(1L);
assertThat(List.of(first, second).stream().filter(Throwable.class::isInstance)
.map(Object::toString).allMatch(text -> !text.contains("SQL") && !text.contains("phone"))).isTrue();
}
private CompanyRegistrationRequest request() {
CompanyRegistrationRequest request = new CompanyRegistrationRequest();
request.setPhone(PHONE); request.setCode(CODE); request.setPassword("Company-strong-password-2026");
CompanyRegistrationRequest.CompanyDetails details = new CompanyRegistrationRequest.CompanyDetails();
details.setName("并发企业"); details.setIndustry("互联网"); details.setDescription("简介"); details.setAddress("地址");
request.setCompany(details);
return request;
}
}

55
mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationFeatureFlagTest.java

@ -0,0 +1,55 @@
package com.mydreamjob.auth.company;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import static org.assertj.core.api.Assertions.assertThat;
class CompanyRegistrationFeatureFlagTest {
@Test
void defaultIsClosed() {
assertThat(new CompanyRegistrationProperties().isEnabled()).isFalse();
}
@Test
void onlyBooleanTrueEnablesTheSwitch() {
CompanyRegistrationProperties properties = new CompanyRegistrationProperties();
properties.setEnabled("true");
assertThat(properties.isEnabled()).isTrue();
properties.setEnabled("false");
assertThat(properties.isEnabled()).isFalse();
}
@Test
void springBindingAcceptsOnlyExactLowercaseTrue() {
ApplicationContextRunner runner = new ApplicationContextRunner()
.withUserConfiguration(BindingConfiguration.class);
for (String value : new String[]{"TRUE", "True", "yes", "1", "on", "false"}) {
runner.withPropertyValues("app.company-registration.enabled=" + value)
.run(context -> assertThat(context.getBean(CompanyRegistrationProperties.class).isEnabled())
.as("feature flag value %s", value)
.isFalse());
}
runner.withPropertyValues("app.company-registration.enabled=true")
.run(context -> assertThat(context.getBean(CompanyRegistrationProperties.class).isEnabled())
.isTrue());
}
@Test
void disabledErrorIsStableAndDoesNotExposeRequestData() {
BusinessException error = new BusinessException(ErrorCode.COMPANY_REGISTRATION_DISABLED);
assertThat(error.getErrorMessage()).isEqualTo("company registration is disabled");
assertThat(error.getMessage()).doesNotContain("phone", "password", "code");
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(CompanyRegistrationProperties.class)
static class BindingConfiguration {
}
}

133
mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationIntegrationTest.java

@ -0,0 +1,133 @@
package com.mydreamjob.auth.company;
import com.mydreamjob.auth.AuthTokenRepository;
import com.mydreamjob.auth.SmsVerificationCode;
import com.mydreamjob.auth.SmsVerificationCodeRepository;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import org.junit.jupiter.api.Test;
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.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
@TestPropertySource(properties = "app.company-registration.enabled=true")
class CompanyRegistrationIntegrationTest {
private static final String PHONE = "19900000121";
private static final String CODE = "123456";
private static final String PASSWORD = "Company-strong-password-2026";
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Autowired private org.springframework.test.web.servlet.MockMvc mockMvc;
@Autowired private UserRepository userRepository;
@Autowired private CompanyRepository companyRepository;
@Autowired private SmsVerificationCodeRepository codeRepository;
@Autowired private AuthTokenRepository authTokenRepository;
@Autowired private PasswordEncoder passwordEncoder;
@Autowired private JdbcTemplate jdbcTemplate;
@Test
@Transactional
void companyRegisterCreatesCompanyIdentityAndSessionAndOnboardingView() throws Exception {
when(redisTemplate.hasKey(anyString())).thenReturn(false);
codeRepository.saveAndFlush(SmsVerificationCode.builder()
.phone(PHONE).purpose("company-register").code(CODE)
.attempts(0).maxAttempts(5).used(false)
.createdAt(LocalDateTime.now()).expiredAt(LocalDateTime.now().plusMinutes(5)).build());
String body = """
{"phone":"%s","code":"%s","password":"%s","company":
{"name":"示例科技","industry":"互联网","description":"企业简介",
"website":"https://example.com","address":"上海"}}
""".formatted(PHONE, CODE, PASSWORD);
String response = mockMvc.perform(post("/auth/company/register")
.contentType(MediaType.APPLICATION_JSON).content(body)
.header("X-Request-Id", "company-register-it"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.userId").isNumber())
.andExpect(jsonPath("$.data.companyId").isNumber())
.andExpect(jsonPath("$.data.role").value("COMPANY"))
.andExpect(jsonPath("$.data.onboardingStatus").value("UNVERIFIED"))
.andExpect(jsonPath("$.data.accessToken").isString())
.andExpect(jsonPath("$.data.refreshToken").isString())
.andReturn().getResponse().getContentAsString();
assertThat(response).doesNotContain(PASSWORD).doesNotContain("passwordHash");
User user = userRepository.findByPhone(PHONE).orElseThrow();
assertThat(user.getRole()).isEqualTo(User.Role.COMPANY);
assertThat(user.getRoles()).containsExactly(User.Role.COMPANY);
assertThat(user.getActive()).isTrue();
assertThat(user.getVerified()).isFalse();
assertThat(user.getPasswordChangedAt()).isNotNull();
assertThat(passwordEncoder.matches(PASSWORD, user.getPasswordHash())).isTrue();
Company company = companyRepository.findByOwnerUserId(user.getId()).orElseThrow();
assertThat(company.getVerified()).isFalse();
assertThat(jdbcTemplate.queryForList(
"SELECT role FROM user_roles WHERE user_id = ?", String.class, user.getId()))
.containsExactly("COMPANY");
assertThat(authTokenRepository.findAll().stream()
.filter(token -> user.getId().equals(token.getUserId())).toList()).hasSize(1);
assertThat(codeRepository.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, "company-register"))
.first().extracting(SmsVerificationCode::getUsed).isEqualTo(true);
String accessToken = com.fasterxml.jackson.databind.json.JsonMapper.builder().build()
.readTree(response).path("data").path("accessToken").asText();
mockMvc.perform(get("/companies/me/onboarding")
.header("Authorization", "Bearer " + accessToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.onboardingStatus").value("UNVERIFIED"))
.andExpect(jsonPath("$.data.company.verified").value(false))
.andExpect(jsonPath("$.data.missingFields").isArray())
.andExpect(jsonPath("$.data.allowedActions[0]").value("UPDATE_PROFILE"));
}
@Test
void invalidRequestShapeIsRejectedBeforePersistence() throws Exception {
mockMvc.perform(post("/auth/company/register")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"phone":"bad","code":"123","password":"weak","company":null}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}
@Test
void companyRegisterDoesNotAcceptClientRoleOrVerificationFields() throws Exception {
mockMvc.perform(post("/auth/company/register")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"phone":"19900000122","code":"123456","password":"Company-strong-password-2026",
"role":"ADMIN","verified":true,"passwordHash":"leak",
"company":{"name":"示例科技","industry":"互联网","description":"企业简介","address":"上海"}}
"""))
.andExpect(status().isBadRequest());
}
}

76
mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationSensitiveDataTest.java

@ -0,0 +1,76 @@
package com.mydreamjob.auth.company;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.AuthSessionService;
import com.mydreamjob.auth.SmsVerificationService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.auth.password.PasswordPolicy;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.deletion.AccountDeletionPhoneHasher;
import com.mydreamjob.deletion.AccountDeletionRepository;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class CompanyRegistrationSensitiveDataTest {
@Test
void responseAndAuditContainNoPasswordCodePhoneHashOrToken() {
String phone = "19900000301";
String password = "Company-strong-password-2026";
CompanyRegistrationProperties properties = new CompanyRegistrationProperties();
properties.setEnabled("true");
SmsVerificationService sms = mock(SmsVerificationService.class);
UserRepository users = mock(UserRepository.class);
CompanyRepository companies = mock(CompanyRepository.class);
AccountDeletionRepository deletions = mock(AccountDeletionRepository.class);
AccountDeletionPhoneHasher hasher = mock(AccountDeletionPhoneHasher.class);
AuthSessionService sessions = mock(AuthSessionService.class);
AuditService audit = mock(AuditService.class);
when(hasher.hash(phone)).thenReturn("h");
when(deletions.findByDeletedPhoneHash("h")).thenReturn(Optional.empty());
when(users.findByPhoneForUpdate(phone)).thenReturn(Optional.empty());
when(users.saveAndFlush(any(User.class))).thenAnswer(invocation -> {
User user = invocation.getArgument(0);
user.setId(301L);
return user;
});
when(companies.saveAndFlush(any(Company.class))).thenAnswer(invocation -> {
Company company = invocation.getArgument(0);
company.setId(401L);
return company;
});
when(sessions.createSession(any(), any(), any()))
.thenReturn(new AuthSessionService.SessionTokens("access-secret", "refresh-secret", "sid"));
CompanyRegistrationService service = new CompanyRegistrationService(properties, sms, users, companies,
new PasswordPolicy(), new BCryptPasswordEncoder(), deletions, hasher, sessions, audit,
Clock.fixed(Instant.parse("2026-09-22T00:00:00Z"), ZoneOffset.UTC));
CompanyRegistrationRequest request = new CompanyRegistrationRequest();
request.setPhone(phone);
request.setCode("123456");
request.setPassword(password);
CompanyRegistrationRequest.CompanyDetails details = new CompanyRegistrationRequest.CompanyDetails();
details.setName("安全测试企业");
details.setIndustry("互联网");
details.setDescription("简介");
details.setAddress("地址");
request.setCompany(details);
CompanyRegistrationResponse response = service.register(request, AuditContext.builder().build());
assertThat(response.toString()).doesNotContain(phone, password);
verify(audit).logRequired(anyLong(), eq("COMPANY_REGISTERED"), anyString(), anyLong(),
argThat(detailsText -> !detailsText.contains(phone) && !detailsText.contains(password)),
any(), any());
}
}

144
mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationServiceTest.java

@ -0,0 +1,144 @@
package com.mydreamjob.auth.company;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.AuthSessionService;
import com.mydreamjob.auth.SmsVerificationService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.auth.password.PasswordPolicy;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.deletion.AccountDeletionPhoneHasher;
import com.mydreamjob.deletion.AccountDeletionRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.dao.DataAccessResourceFailureException;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CompanyRegistrationServiceTest {
private static final String PHONE = "19900000111";
@Mock SmsVerificationService sms;
@Mock UserRepository users;
@Mock CompanyRepository companies;
@Mock AccountDeletionRepository deletions;
@Mock AccountDeletionPhoneHasher hasher;
@Mock AuthSessionService sessions;
@Mock AuditService audit;
private final PasswordEncoder encoder = new BCryptPasswordEncoder();
private final PasswordPolicy policy = new PasswordPolicy();
private CompanyRegistrationProperties properties;
private CompanyRegistrationService service;
@BeforeEach
void setUp() {
properties = new CompanyRegistrationProperties();
properties.setEnabled("true");
service = new CompanyRegistrationService(properties, sms, users, companies, policy, encoder,
deletions, hasher, sessions, audit,
Clock.fixed(Instant.parse("2026-09-22T00:00:00Z"), ZoneOffset.UTC));
}
@Test
void createsCompanyUserCompanyAndSession() {
when(hasher.hash(PHONE)).thenReturn("phone-hash");
when(deletions.findByDeletedPhoneHash("phone-hash")).thenReturn(Optional.empty());
when(users.findByPhoneForUpdate(PHONE)).thenReturn(Optional.empty());
when(users.saveAndFlush(any(User.class))).thenAnswer(inv -> {
User u = inv.getArgument(0);
u.setId(11L);
return u;
});
when(companies.saveAndFlush(any(Company.class))).thenAnswer(inv -> {
Company c = inv.getArgument(0);
c.setId(21L);
return c;
});
when(sessions.createSession(any(), any(), any()))
.thenReturn(new AuthSessionService.SessionTokens("access", "refresh", "sid"));
CompanyRegistrationResponse result = service.register(request(), AuditContext.builder().build());
assertThat(result.role()).isEqualTo("COMPANY");
assertThat(result.onboardingStatus()).isEqualTo("UNVERIFIED");
verify(sms).verifyCode(PHONE, "123456", "company-register");
verify(users).saveAndFlush(argThat(u -> u.getRole() == User.Role.COMPANY
&& u.getRoles().contains(User.Role.COMPANY)
&& u.getRoles().size() == 1
&& !u.getVerified()
&& u.getPasswordHash() != null));
verify(companies).saveAndFlush(argThat(c -> c.getOwnerUserId().equals(11L)
&& !c.getVerified()));
verify(audit).logRequired(eq(11L), eq("COMPANY_REGISTERED"), eq("COMPANY"), eq(21L),
anyString(), any(), any());
}
@Test
void disabledRegistrationFailsBeforeSmsConsumption() {
properties.setEnabled("false");
assertThatThrownBy(() -> service.register(request(), AuditContext.builder().build()))
.isInstanceOf(BusinessException.class)
.extracting("errorCodeInt").isEqualTo(ErrorCode.COMPANY_REGISTRATION_DISABLED.getCode());
verifyNoInteractions(sms, users, companies);
}
@Test
void dependencyFailureIsMappedToStableServiceUnavailableCode() {
when(sms.verifyCode(anyString(), anyString(), eq("company-register")))
.thenThrow(new DataAccessResourceFailureException("connection details must not escape"));
assertThatThrownBy(() -> service.register(request(), AuditContext.builder().build()))
.isInstanceOf(BusinessException.class)
.extracting("errorCodeInt")
.isEqualTo(ErrorCode.COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE.getCode());
verifyNoInteractions(users, companies, sessions, audit);
}
@Test
void allExistingPrincipalRolesUseTheSameConflictResponse() {
when(hasher.hash(PHONE)).thenReturn("phone-hash");
when(deletions.findByDeletedPhoneHash("phone-hash")).thenReturn(Optional.empty());
for (User.Role role : User.Role.values()) {
reset(users);
User existing = User.builder().id(100L).phone(PHONE).role(role).roles(java.util.Set.of(role)).build();
when(users.findByPhoneForUpdate(PHONE)).thenReturn(Optional.of(existing));
assertThatThrownBy(() -> service.register(request(), AuditContext.builder().build()))
.isInstanceOf(BusinessException.class)
.extracting("errorCodeInt")
.isEqualTo(ErrorCode.COMPANY_REGISTRATION_CONFLICT.getCode());
}
}
private CompanyRegistrationRequest request() {
CompanyRegistrationRequest request = new CompanyRegistrationRequest();
request.setPhone(PHONE);
request.setCode("123456");
request.setPassword("StrongCompanyPassword#1");
CompanyRegistrationRequest.CompanyDetails details = new CompanyRegistrationRequest.CompanyDetails();
details.setName("示例企业");
details.setIndustry("互联网");
details.setDescription("企业简介");
details.setAddress("上海");
request.setCompany(details);
return request;
}
}

145
mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationTransactionRollbackTest.java

@ -0,0 +1,145 @@
package com.mydreamjob.auth.company;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.*;
import com.mydreamjob.auth.password.PasswordPolicy;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.Company;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
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.boot.test.mock.mockito.SpyBean;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.h2.api.Trigger;
import java.sql.Connection;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.doThrow;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
@TestPropertySource(properties = "app.company-registration.enabled=true")
class CompanyRegistrationTransactionRollbackTest {
private static final String PHONE = "19900000311";
@Autowired CompanyRegistrationService service;
@Autowired SmsVerificationCodeRepository codes;
@Autowired UserRepository users;
@Autowired CompanyRepository companies;
@Autowired AuthTokenRepository tokens;
@Autowired JdbcTemplate jdbc;
@MockBean RedisTemplate<String, Object> redis;
@MockBean AuditService audit;
@SpyBean UserRepository userRepositorySpy;
@SpyBean CompanyRepository companyRepositorySpy;
@SpyBean AuthSessionService authSessionServiceSpy;
@AfterEach
void cleanup() {
jdbc.update("DELETE FROM auth_tokens WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE);
jdbc.update("DELETE FROM companies WHERE owner_user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE);
jdbc.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE);
jdbc.update("DELETE FROM users WHERE phone = ?", PHONE);
jdbc.update("DELETE FROM sms_verification_codes WHERE phone = ?", PHONE);
}
@Test
void auditFailureRollsBackUserRoleCompanySessionAndCodeConsumption() {
doThrow(new DataAccessResourceFailureException("audit unavailable")).when(audit)
.logRequired(anyLong(), eq("COMPANY_REGISTERED"), anyString(), anyLong(), anyString(), any(), any());
assertRollbackAfterInjectedFailure("audit");
}
@Test
void userSaveFailureRollsBackAllRowsAndCodeConsumption() {
doThrow(new DataAccessResourceFailureException("user store unavailable"))
.when(userRepositorySpy).saveAndFlush(any(User.class));
assertRollbackAfterInjectedFailure("user");
}
@Test
void userRoleFlushFailureRollsBackAllRowsAndCodeConsumption() {
createUserRoleFailureTrigger();
try {
assertRollbackAfterInjectedFailure("user-role");
} finally {
jdbc.execute("DROP TRIGGER IF EXISTS phase_a_fail_user_roles");
}
}
@Test
void companySaveFailureRollsBackAllRowsAndCodeConsumption() {
doThrow(new DataAccessResourceFailureException("company store unavailable"))
.when(companyRepositorySpy).saveAndFlush(any(Company.class));
assertRollbackAfterInjectedFailure("company");
}
@Test
void sessionCreationFailureRollsBackAllRowsAndCodeConsumption() {
doThrow(new DataAccessResourceFailureException("session store unavailable"))
.when(authSessionServiceSpy).createSession(any(User.class), any(Instant.class), any(AuditContext.class));
assertRollbackAfterInjectedFailure("session");
}
private void assertRollbackAfterInjectedFailure(String suffix) {
org.mockito.Mockito.when(redis.hasKey(anyString())).thenReturn(false);
codes.saveAndFlush(SmsVerificationCode.builder().phone(PHONE).purpose("company-register")
.code("123456").attempts(0).maxAttempts(5).used(false)
.createdAt(LocalDateTime.now()).expiredAt(LocalDateTime.now().plusMinutes(5)).build());
CompanyRegistrationRequest request = new CompanyRegistrationRequest();
request.setPhone(PHONE); request.setCode("123456"); request.setPassword("Company-strong-password-2026");
CompanyRegistrationRequest.CompanyDetails details = new CompanyRegistrationRequest.CompanyDetails();
details.setName("回滚企业-" + suffix); details.setIndustry("互联网"); details.setDescription("简介"); details.setAddress("地址");
request.setCompany(details);
assertThatThrownBy(() -> service.register(request, AuditContext.builder().build()))
.isInstanceOf(com.mydreamjob.common.exception.BusinessException.class);
assertThat(users.findByPhone(PHONE)).isEmpty();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", Long.class, PHONE)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM companies WHERE name = ?", Long.class, "回滚企业-" + suffix)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM auth_tokens WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", Long.class, PHONE)).isZero();
assertThat(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, "company-register"))
.singleElement().extracting(SmsVerificationCode::getUsed).isEqualTo(false);
}
private void createUserRoleFailureTrigger() {
jdbc.execute("CREATE TRIGGER phase_a_fail_user_roles BEFORE INSERT ON user_roles "
+ "FOR EACH ROW CALL '" + FailUserRoleInsertTrigger.class.getName() + "'");
}
/** H2-only failure injector: the production transaction still executes real SQL. */
public static final class FailUserRoleInsertTrigger implements Trigger {
@Override
public void init(Connection conn, String schemaName, String triggerName,
String tableName, boolean before, int type) {
}
@Override
public void fire(Connection conn, Object[] oldRow, Object[] newRow) throws SQLException {
throw new SQLException("user role store unavailable");
}
@Override
public void close() {
}
@Override
public void remove() {
}
}
}

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

@ -64,13 +64,16 @@ class GlobalExceptionHandlerStatusContractTest {
"ASSESSMENT_DUPLICATE_ACTIVE", "ASSESSMENT_IDEMPOTENCY_CONFLICT",
"ABILITY_ATTEMPT_INVALID_STATE", "ABILITY_CONCURRENT_UPDATE", "ABILITY_DUPLICATE_ACTIVE",
"ABILITY_IDEMPOTENCY_CONFLICT", "ABILITY_RETAKE_COOLDOWN",
"RISK_RULE_CONCURRENT_UPDATE", "RISK_EVENT_CONCURRENT_UPDATE", "RISK_INVALID_ACTION");
"RISK_RULE_CONCURRENT_UPDATE", "RISK_EVENT_CONCURRENT_UPDATE", "RISK_INVALID_ACTION",
"COMPANY_REGISTRATION_CONFLICT", "COMPANY_ONBOARDING_INCONSISTENT_STATE");
private static final Set<String> TOO_MANY_REQUESTS = Set.of(
"REPORT_RATE_LIMITED", "ABILITY_RETAKE_LIMIT", "TALENT_INVITATION_RATE_LIMITED",
"PASSWORD_RATE_LIMITED");
private static final Set<String> UNAUTHORIZED = Set.of("CREDENTIALS_INVALID");
private static final Set<String> SERVICE_UNAVAILABLE = Set.of(
"COMPANY_REGISTRATION_DISABLED", "COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE");
private final GlobalExceptionHandler handler = new GlobalExceptionHandler(new RequestIdAccessor(), new ErrorHttpStatusRegistry());
@ -106,6 +109,7 @@ class GlobalExceptionHandlerStatusContractTest {
if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS;
if ("PASSWORD_STATE_CONFLICT".equals(name)) return HttpStatus.CONFLICT;
if (UNAUTHORIZED.contains(name)) return HttpStatus.UNAUTHORIZED;
if (SERVICE_UNAVAILABLE.contains(name)) return HttpStatus.SERVICE_UNAVAILABLE;
return HttpStatus.BAD_REQUEST;
}
}

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

@ -28,15 +28,16 @@ class ErrorHttpStatusRegistryTest {
/** 允许出现在显式映射里的状态集合;密码登录新增 401/400 契约。 */
private static final Set<HttpStatus> ALLOWED_EXPLICIT_STATUSES = Set.of(
HttpStatus.FORBIDDEN, HttpStatus.NOT_FOUND, HttpStatus.CONFLICT,
HttpStatus.TOO_MANY_REQUESTS, HttpStatus.UNAUTHORIZED, HttpStatus.BAD_REQUEST);
HttpStatus.TOO_MANY_REQUESTS, HttpStatus.UNAUTHORIZED, HttpStatus.BAD_REQUEST,
HttpStatus.SERVICE_UNAVAILABLE);
@Test
@DisplayName("密码登录:显式映射包含新增认证状态")
void explicitMappingCountMatchesBaseline() {
assertThat(registry.explicitMappingCount()).isEqualTo(115);
// 116 个常量只覆盖 115 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与
assertThat(registry.explicitMappingCount()).isEqualTo(119);
// 119 个常量只覆盖 118 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与
// DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。
assertThat(registry.explicitNumericCodeCount()).isEqualTo(114);
assertThat(registry.explicitNumericCodeCount()).isEqualTo(118);
}
@Test
@ -121,12 +122,12 @@ class ErrorHttpStatusRegistryTest {
.isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN))
.isInstanceOf(UnsupportedOperationException.class);
assertThat(view).hasSize(115);
assertThat(view).hasSize(119);
Map<Integer, HttpStatus> numeric = ErrorHttpStatusRegistry.numericMappingsView();
assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK))
.isInstanceOf(UnsupportedOperationException.class);
assertThat(numeric).hasSize(114);
assertThat(numeric).hasSize(118);
}
@Test

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

@ -86,6 +86,10 @@ class GlobalExceptionHandlerMappingParityTest {
private static final Set<String> UNAUTHORIZED = Set.of("CREDENTIALS_INVALID");
private static final Set<String> PASSWORD_BAD_REQUEST = Set.of("PASSWORD_POLICY_INVALID");
private static final Set<String> PHASE_A_CONFLICT = Set.of(
"COMPANY_REGISTRATION_CONFLICT", "COMPANY_ONBOARDING_INCONSISTENT_STATE");
private static final Set<String> PHASE_A_SERVICE_UNAVAILABLE = Set.of(
"COMPANY_REGISTRATION_DISABLED", "COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE");
private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry();
private final GlobalExceptionHandler handler =
@ -97,8 +101,10 @@ class GlobalExceptionHandlerMappingParityTest {
if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND;
if (CONFLICT.contains(name)) return HttpStatus.CONFLICT;
if (CONFLICT_ADDITIONS.contains(name)) return HttpStatus.CONFLICT;
if (PHASE_A_CONFLICT.contains(name)) return HttpStatus.CONFLICT;
if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS;
if (UNAUTHORIZED.contains(name)) return HttpStatus.UNAUTHORIZED;
if (PHASE_A_SERVICE_UNAVAILABLE.contains(name)) return HttpStatus.SERVICE_UNAVAILABLE;
if (PASSWORD_BAD_REQUEST.contains(name)) return HttpStatus.BAD_REQUEST;
return HttpStatus.BAD_REQUEST;
}
@ -106,8 +112,8 @@ class GlobalExceptionHandlerMappingParityTest {
@Test
@DisplayName("B2:显式映射集合与重构前快照逐项相等")
void explicitMappingSetEqualsPreRefactorSnapshot() {
assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(115);
assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(114);
assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(119);
assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(118);
for (ErrorCode errorCode : ErrorCode.values()) {
HttpStatus expected = baselineStatus(errorCode);

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

@ -35,6 +35,11 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
class R01BIndependentHttpMappingParityTest {
private static final String BASELINE = "/r01b/error-code-http-status-baseline.tsv";
private static final Map<String, HttpStatus> PHASE_A_ADDITIONS = Map.of(
"COMPANY_REGISTRATION_DISABLED", HttpStatus.SERVICE_UNAVAILABLE,
"COMPANY_REGISTRATION_CONFLICT", HttpStatus.CONFLICT,
"COMPANY_ONBOARDING_INCONSISTENT_STATE", HttpStatus.CONFLICT,
"COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE", HttpStatus.SERVICE_UNAVAILABLE);
private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry();
private final GlobalExceptionHandler handler =
@ -78,14 +83,20 @@ class R01BIndependentHttpMappingParityTest {
void everyEnumConstantMatchesHeadBaselineByName() {
Map<String, int[]> base = baseline();
// 169 条,且 9103 的两个常量各自成行 —— 不能按数字去重后只剩 168 条
// HEAD 基线有 169 条;Phase A 追加 3 个错误码,不修改任何基线行。
assertThat(base).hasSize(169);
assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK");
assertThat(ErrorCode.values()).hasSize(169);
assertThat(ErrorCode.values()).hasSize(173);
int checked = 0;
for (ErrorCode code : ErrorCode.values()) {
int[] expected = base.get(code.name());
if (expected == null) {
assertThat(PHASE_A_ADDITIONS).containsKey(code.name());
assertThat(registry.resolve(code)).isEqualTo(PHASE_A_ADDITIONS.get(code.name()));
checked++;
continue;
}
assertThat(expected).as("基线必须覆盖 %s", code.name()).isNotNull();
assertThat(code.getCode()).as("%s 的数字码不得改变", code.name()).isEqualTo(expected[0]);
assertThat(registry.resolve(code).value())
@ -93,7 +104,7 @@ class R01BIndependentHttpMappingParityTest {
.isEqualTo(expected[1]);
checked++;
}
assertThat(checked).isEqualTo(169);
assertThat(checked).isEqualTo(173);
}
@Test
@ -105,10 +116,10 @@ class R01BIndependentHttpMappingParityTest {
assertThat(explicitInBaseline).isEqualTo(115);
assertThat(base.size() - explicitInBaseline).isEqualTo(54);
assertThat(registry.explicitMappingCount()).isEqualTo(115);
assertThat(registry.explicitMappingCount()).isEqualTo(119);
assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54);
// 115 个常量仅覆盖 114 个唯一数字码(9103 共用)
assertThat(registry.explicitNumericCodeCount()).isEqualTo(114);
// 118 个常量仅覆盖 117 个唯一数字码(9103 共用)
assertThat(registry.explicitNumericCodeCount()).isEqualTo(118);
}
@Test
@ -118,6 +129,8 @@ class R01BIndependentHttpMappingParityTest {
assertThat(registry.resolve(ErrorCode.CREDENTIALS_INVALID)).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(registry.resolve(ErrorCode.FORBIDDEN)).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(ErrorCode.COMPANY_REGISTRATION_DISABLED))
.isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(registry.resolve(ErrorCode.RATE_LIMITED)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(ErrorCode.USER_NOT_FOUND)).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(registry.resolve(ErrorCode.APPLICATION_DUPLICATE)).isEqualTo(HttpStatus.CONFLICT);
@ -218,7 +231,11 @@ class R01BIndependentHttpMappingParityTest {
// 真实注册表对每个枚举常量的解析与其声明位置无关:逐个比对基线即可证明
Map<String, int[]> base = baseline();
for (ErrorCode c : ErrorCode.values()) {
assertThat(registry.resolve(c).value()).isEqualTo(base.get(c.name())[1]);
if (PHASE_A_ADDITIONS.containsKey(c.name())) {
assertThat(registry.resolve(c)).isEqualTo(PHASE_A_ADDITIONS.get(c.name()));
} else {
assertThat(registry.resolve(c).value()).isEqualTo(base.get(c.name())[1]);
}
}
}

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

@ -29,6 +29,9 @@ class CompanyControllerMvcTest {
@MockBean
private CompanyService companyService;
@MockBean
private CompanyOnboardingService companyOnboardingService;
@Test
void getCompany_nonNumericId_shouldReturn400WithCode1001() throws Exception {
mockMvc.perform(get("/companies/abc"))

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

@ -0,0 +1,46 @@
package com.mydreamjob.company;
import com.mydreamjob.auth.AuthSessionService;
import com.mydreamjob.auth.JwtTokenProvider;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.security.CurrentUserAccessor;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(CompanyController.class)
@AutoConfigureMockMvc(addFilters = false)
@MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class, AuthSessionService.class,
java.time.Clock.class, RequestIdAccessor.class, CurrentUserAccessor.class})
class CompanyOnboardingControllerTest {
@Autowired MockMvc mockMvc;
@MockBean CompanyService companyService;
@MockBean CompanyOnboardingService onboardingService;
@MockBean CurrentUserAccessor userAccessor;
@Test
void onboardingEndpointReturnsSafeAggregateAndUsesAuthenticatedUser() throws Exception {
when(userAccessor.requireUserId()).thenReturn(77L);
when(onboardingService.get(77L)).thenReturn(new CompanyOnboardingResponse(
new CompanyOnboardingResponse.CompanySummary(12L, "企业", null, "简介", "互联网", null, "地址", false),
"UNVERIFIED", null, List.of("INDUSTRY"), List.of("UPDATE_PROFILE", "SUBMIT_VERIFICATION")));
mockMvc.perform(get("/companies/me/onboarding"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.onboardingStatus").value("UNVERIFIED"))
.andExpect(jsonPath("$.data.allowedActions[1]").value("SUBMIT_VERIFICATION"))
.andExpect(jsonPath("$.data.company.name").value("企业"));
verify(onboardingService).get(77L);
}
}

84
mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyOnboardingServiceTest.java

@ -0,0 +1,84 @@
package com.mydreamjob.company;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.company.CompanyOnboardingResponse.VerificationSummary;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.verification.CompanyVerification;
import com.mydreamjob.verification.CompanyVerificationRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CompanyOnboardingServiceTest {
@Mock UserRoleGuard guard;
@Mock CompanyRepository companies;
@Mock CompanyVerificationRepository verifications;
@Test
void derivesUnverifiedAndDoesNotExposeReviewDetails() {
Company company = Company.builder().id(7L).ownerUserId(9L).name("企业")
.industry("互联网").verified(false).build();
when(companies.findByOwnerUserId(9L)).thenReturn(Optional.of(company));
when(verifications.findTopByCompanyIdOrderByIdDesc(7L)).thenReturn(Optional.empty());
CompanyOnboardingResponse result = new CompanyOnboardingService(guard, companies, verifications).get(9L);
assertThat(result.onboardingStatus()).isEqualTo("UNVERIFIED");
assertThat(result.latestVerification()).isNull();
assertThat(result.allowedActions()).containsExactly("UPDATE_PROFILE", "SUBMIT_VERIFICATION");
verify(guard).requireCompany(9L);
}
@Test
void derivesPendingWithoutReturningSensitiveMaterial() {
Company company = Company.builder().id(7L).ownerUserId(9L).name("企业")
.industry("互联网").description("简介").address("地址").verified(false).build();
CompanyVerification verification = CompanyVerification.builder().id(8L).companyId(7L)
.status("RESUBMITTED").materialsJson("secret-material").decisionReason("internal").build();
when(companies.findByOwnerUserId(9L)).thenReturn(Optional.of(company));
when(verifications.findTopByCompanyIdOrderByIdDesc(7L)).thenReturn(Optional.of(verification));
VerificationSummary result = new CompanyOnboardingService(guard, companies, verifications).get(9L)
.latestVerification();
assertThat(result.status()).isEqualTo("RESUBMITTED");
assertThat(result).hasNoNullFieldsOrPropertiesExcept("submittedAt", "decidedAt");
verifyNoMoreInteractions(companies, verifications);
}
@Test
void verifiedCompanyWinsOverPendingReview() {
Company company = Company.builder().id(17L).ownerUserId(19L).name("已认证企业")
.verified(true).build();
when(companies.findByOwnerUserId(19L)).thenReturn(Optional.of(company));
when(verifications.findTopByCompanyIdOrderByIdDesc(17L)).thenReturn(Optional.of(
CompanyVerification.builder().id(18L).companyId(17L).status("PENDING").build()));
CompanyOnboardingResponse result = new CompanyOnboardingService(guard, companies, verifications).get(19L);
assertThat(result.onboardingStatus()).isEqualTo("VERIFIED");
assertThat(result.allowedActions()).containsExactly("ENTER_COMPANY_WORKSPACE");
}
@Test
void approvedReviewWithoutCompanyVerificationFailsClosed() {
Company company = Company.builder().id(27L).ownerUserId(29L).name("不一致企业")
.verified(false).build();
when(companies.findByOwnerUserId(29L)).thenReturn(Optional.of(company));
when(verifications.findTopByCompanyIdOrderByIdDesc(27L)).thenReturn(Optional.of(
CompanyVerification.builder().id(28L).companyId(27L).status("APPROVED").build()));
assertThatThrownBy(() -> new CompanyOnboardingService(guard, companies, verifications).get(29L))
.isInstanceOf(BusinessException.class)
.extracting("errorCodeInt")
.isEqualTo(com.mydreamjob.common.exception.ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE.getCode());
}
}
Loading…
Cancel
Save