diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 260f842..288bd14 100644 --- a/docs/openapi-rc.json +++ b/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": { diff --git a/docs/phase-a-company-registration-onboarding-implementation-report.md b/docs/phase-a-company-registration-onboarding-implementation-report.md new file mode 100644 index 0000000..9bca1ba --- /dev/null +++ b/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**,均无失败、错误或跳过。 diff --git a/docs/phase-a-company-registration-onboarding-independent-verification.md b/docs/phase-a-company-registration-onboarding-independent-verification.md new file mode 100644 index 0000000..6a6b383 --- /dev/null +++ b/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。 diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index d16a3f1..af2a7db 100644 --- a/docs/verify_openapi.py +++ b/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)") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java b/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java index 01ff687..9424878 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java +++ b/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 { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java index 221c039..7ab0c72 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCodeRepository.java +++ b/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 { + @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 findByPhoneAndCreatedAtAfter(String phone, LocalDateTime after); /** diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java index 5910845..c2d0723 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java +++ b/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 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"); } } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationProperties.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationProperties.java new file mode 100644 index 0000000..6e7a294 --- /dev/null +++ b/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); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java new file mode 100644 index 0000000..2e79705 --- /dev/null +++ b/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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationResponse.java new file mode 100644 index 0000000..15f7d61 --- /dev/null +++ b/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) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationService.java new file mode 100644 index 0000000..245b6f4 --- /dev/null +++ b/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); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index 7f514ee..d7c4f2f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/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; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java index 0715134..0833cf7 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java +++ b/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, diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java index 14d9bcf..5bd872a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java +++ b/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> getMyOnboarding(HttpServletRequest request) { + Long userId = currentUserAccessor.requireUserId(); + return ResponseEntity.ok(ApiResponse.ok(companyOnboardingService.get(userId), requestIdAccessor.get(request))); + } // ── 创建 ────────────────────────────────────────────────── diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingResponse.java new file mode 100644 index 0000000..ea9ff2f --- /dev/null +++ b/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 missingFields, + List 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) { + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyOnboardingService.java new file mode 100644 index 0000000..809b892 --- /dev/null +++ b/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 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 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(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index 24bfefc..b0f669c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/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 校验。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java index eda89f0..d7c66ca 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java +++ b/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> 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") diff --git a/mydreamjob-server/src/main/resources/application.yml b/mydreamjob-server/src/main/resources/application.yml index 8b69fea..6c2ad5a 100644 --- a/mydreamjob-server/src/main/resources/application.yml +++ b/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 用它派生候选人展示键; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java index 4684593..aa70296 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java +++ b/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(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsPurposeAllowlistTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsPurposeAllowlistTest.java new file mode 100644 index 0000000..ae205c8 --- /dev/null +++ b/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 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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationAtomicConsumptionTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationAtomicConsumptionTest.java new file mode 100644 index 0000000..5dd6e4b --- /dev/null +++ b/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 redis; + @Mock ValueOperations 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(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java index bde98a2..fafbff4 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java +++ b/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)) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java index 2953819..d0ef351 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java +++ b/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 不应被验证通过 diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationConcurrencyMySqlTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationConcurrencyMySqlTest.java new file mode 100644 index 0000000..ae6ab09 --- /dev/null +++ b/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 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> 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; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationFeatureFlagTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationFeatureFlagTest.java new file mode 100644 index 0000000..79eb411 --- /dev/null +++ b/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 { + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationIntegrationTest.java new file mode 100644 index 0000000..ba80642 --- /dev/null +++ b/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 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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationSensitiveDataTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationSensitiveDataTest.java new file mode 100644 index 0000000..286e80d --- /dev/null +++ b/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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationServiceTest.java new file mode 100644 index 0000000..03397ce --- /dev/null +++ b/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; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationTransactionRollbackTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/company/CompanyRegistrationTransactionRollbackTest.java new file mode 100644 index 0000000..dc72e0e --- /dev/null +++ b/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 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() { + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java index cb0a054..8d322ae 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java +++ b/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 TOO_MANY_REQUESTS = Set.of( "REPORT_RATE_LIMITED", "ABILITY_RETAKE_LIMIT", "TALENT_INVITATION_RATE_LIMITED", "PASSWORD_RATE_LIMITED"); private static final Set UNAUTHORIZED = Set.of("CREDENTIALS_INVALID"); + private static final Set 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; } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java index c4d6535..5de1339 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java @@ -28,15 +28,16 @@ class ErrorHttpStatusRegistryTest { /** 允许出现在显式映射里的状态集合;密码登录新增 401/400 契约。 */ private static final Set 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 numeric = ErrorHttpStatusRegistry.numericMappingsView(); assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK)) .isInstanceOf(UnsupportedOperationException.class); - assertThat(numeric).hasSize(114); + assertThat(numeric).hasSize(118); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java index b8f1abc..33efd4e 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java @@ -86,6 +86,10 @@ class GlobalExceptionHandlerMappingParityTest { private static final Set UNAUTHORIZED = Set.of("CREDENTIALS_INVALID"); private static final Set PASSWORD_BAD_REQUEST = Set.of("PASSWORD_POLICY_INVALID"); + private static final Set PHASE_A_CONFLICT = Set.of( + "COMPANY_REGISTRATION_CONFLICT", "COMPANY_ONBOARDING_INCONSISTENT_STATE"); + private static final Set 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); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java index 35affe3..6bd1df2 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java +++ b/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 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 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 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]); + } } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java index 26cfcaa..0806ae4 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java +++ b/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")) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyOnboardingControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyOnboardingControllerTest.java new file mode 100644 index 0000000..878f5d7 --- /dev/null +++ b/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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyOnboardingServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyOnboardingServiceTest.java new file mode 100644 index 0000000..7a21f90 --- /dev/null +++ b/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()); + } +}