Browse Source

feat: harden production bootstrap and resume privacy

master
Zhang Shun 1 month ago
parent
commit
cb3bc57fab
  1. 1
      docs/backend-development-roadmap.md
  2. 24
      docs/frontend-integration.md
  3. 34
      docs/openapi-rc.json
  4. 1
      docs/phase1-scope-and-acceptance.md
  5. 29
      docs/privacy-settings-api.md
  6. 39
      docs/resume-api.md
  7. 167
      docs/s03-independent-mysql-acceptance-20260915.md
  8. 167
      docs/s03-production-security-and-resume-privacy-design.md
  9. 270
      docs/s03-production-security-and-resume-privacy-implementation-instruction.md
  10. 34
      docs/verify_openapi.py
  11. 5
      mydreamjob-server/pom.xml
  12. 29
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java
  13. 132
      mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java
  14. 72
      mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java
  15. 10
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CompanyResumeFile.java
  16. 105
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapCreator.java
  17. 117
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapRunner.java
  18. 12
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
  19. 28
      mydreamjob-server/src/main/java/com/mydreamjob/config/AdminBootstrapProperties.java
  20. 11
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  21. 49
      mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java
  22. 25
      mydreamjob-server/src/main/resources/application.yml
  23. 40
      mydreamjob-server/src/main/resources/db/migration/V43__disable_legacy_seed_accounts.sql
  24. 320
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeAccessPolicyTest.java
  25. 606
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  26. 299
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapConcurrencyLoggingTest.java
  27. 41
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapDisabledByDefaultTest.java
  28. 235
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapIntegrationTest.java
  29. 237
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapRunnerTest.java
  30. 112
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapTransactionTest.java
  31. 223
      mydreamjob-server/src/test/java/com/mydreamjob/auth/LegacySeedAccountDisableMigrationTest.java
  32. 15
      mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokens.java
  33. 317
      mydreamjob-server/src/test/java/com/mydreamjob/config/ActuatorIsolationIntegrationTest.java
  34. 90
      mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java

1
docs/backend-development-roadmap.md

@ -13,6 +13,7 @@
| 消息撤回与会话一致性 | N07 定向整改完成并通过自动化与干净构建,待独立代码复验与真实 MySQL/Docker 验收 | 两分钟窗口、发送者权限、正文覆盖、幂等并发、DIRECT/邀约隐私均已锁定 | | 消息撤回与会话一致性 | N07 定向整改完成并通过自动化与干净构建,待独立代码复验与真实 MySQL/Docker 验收 | 两分钟窗口、发送者权限、正文覆盖、幂等并发、DIRECT/邀约隐私均已锁定 |
| S01 认证安全整改 | 独立代码复验与真实 MySQL/Docker 黑盒全部通过(`b5084fd`) | 保持验证码受控取码、双开关与 `/jobs` 认证边界不变 | | S01 认证安全整改 | 独立代码复验与真实 MySQL/Docker 黑盒全部通过(`b5084fd`) | 保持验证码受控取码、双开关与 `/jobs` 认证边界不变 |
| S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 |
| S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 |
| 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 |

24
docs/frontend-integration.md

@ -990,3 +990,27 @@ V43 仅供管理员后台使用,前端不得向学生或企业展示。当前
可用接口、参数和小样本规则见 [candidate-scoring-fairness-api.md](candidate-scoring-fairness-api.md)。前端不得根据城市、学校、姓名等字段创建敏感人群筛选,也不得尝试通过多次小样本查询反推出候选人。 可用接口、参数和小样本规则见 [candidate-scoring-fairness-api.md](candidate-scoring-fairness-api.md)。前端不得根据城市、学校、姓名等字段创建敏感人群筛选,也不得尝试通过多次小样本查询反推出候选人。
总览、维度和申诉响应都显式返回 `availability/reason`。状态/版本/回滚原因及分布桶均按独立候选人数计算;`appealCount` 仅表示事件数。`SUPPRESSED` 时不得用总体计数、补集或其它接口字段反推出被隐藏比例,`NO_DATA`、`INSUFFICIENT_SAMPLE`、`SUPPRESSED` 均不得渲染为 `NORMAL`。 总览、维度和申诉响应都显式返回 `availability/reason`。状态/版本/回滚原因及分布桶均按独立候选人数计算;`appealCount` 仅表示事件数。`SUPPRESSED` 时不得用总体计数、补集或其它接口字段反推出被隐藏比例,`NO_DATA`、`INSUFFICIENT_SAMPLE`、`SUPPRESSED` 均不得渲染为 `NORMAL`。
## 13. 企业下载候选人简历(S03)
```
GET /api/v1/company/candidates/{applicationId}/resume
Authorization: Bearer <COMPANY 用户 accessToken>
```
响应是**文件流**(不是 JSON 包装),前端应按二进制处理:
- 成功:`200` + `Content-Type`(PDF/DOC/DOCX)+ `Content-Disposition: attachment; filename*=UTF-8''…`
+ `Cache-Control: no-store`
- `401` 未登录或会话失效
- `403` 非 COMPANY 角色、无企业资料、跨企业投递,或候选人隐私设置不允许
- `404` 投递不存在(`APPLICATION_NOT_FOUND`)或学生没有简历(`PROFILE_NOT_FOUND`)
前端注意事项:
1. **不要**从候选人列表/详情里找 `resumeUrl` —— 这些 DTO 有意不含该字段;
下载必须走上面这个接口。
2. 403 与 404 都按「不可下载」处理,**不要**在 UI 上区分「隐私拒绝」与「跨企业」,
避免泄露归属信息。
3. 下载链接不要放进 URL 或日志;如需预览,请用同一 Authorization 头请求。
4. 响应已声明 `no-store`,前端不要再把简历文件写入本地持久缓存。

34
docs/openapi-rc.json

@ -9267,6 +9267,40 @@
} }
} }
} }
},
"/company/candidates/{applicationId}/resume": {
"get": {
"tags": [
"投递"
],
"summary": "企业下载候选人简历",
"description": "按候选人隐私设置授权后返回简历文件流;不返回存储路径、学生 ID 或联系方式",
"operationId": "downloadCandidateResume",
"parameters": [
{
"name": "applicationId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"type": "string",
"format": "byte"
}
}
}
}
}
}
} }
}, },
"components": { "components": {

1
docs/phase1-scope-and-acceptance.md

@ -31,6 +31,7 @@
| C08 推荐与人才邀约 | 岗位推荐、学生授权发现、企业邀约与接受后沟通 | recommendation/、talentdiscovery/、talentinvitation/ | 默认不发现;同企业历史投递排除;限额/冷却/幂等;接受后会话隐私 | 张顺 / 小刘 | | C08 推荐与人才邀约 | 岗位推荐、学生授权发现、企业邀约与接受后沟通 | recommendation/、talentdiscovery/、talentinvitation/ | 默认不发现;同企业历史投递排除;限额/冷却/幂等;接受后会话隐私 | 张顺 / 小刘 |
| S01 认证安全整改 | 验证码不落日志、受控取码、`/jobs` 认证边界 | auth/、docs/smoke-test.sh、job/JobController.java | 双开关受控取码;mock=false 完整回滚;`/jobs` 权限矩阵(独立代码复验与真实 MySQL/Docker 黑盒已通过) | 张顺 / 独立复验 | | S01 认证安全整改 | 验证码不落日志、受控取码、`/jobs` 认证边界 | auth/、docs/smoke-test.sh、job/JobController.java | 双开关受控取码;mock=false 完整回滚;`/jobs` 权限矩阵(独立代码复验与真实 MySQL/Docker 黑盒已通过) | 张顺 / 独立复验 |
| S02 Token 隔离与会话吊销 | Access/Refresh 类型与密钥隔离、轮换、重放处置、会话吊销 | auth/(JwtTokenProvider、AuthSessionService、AuthTokenHasher、JwtAuthenticationFilter、AuthController)、V42__auth_token_sessions.sql | Refresh 不能访问普通接口;Access 不能刷新;二次使用撤销当前会话;并发刷新最多成功一次;登出/注销完成使会话失效 | 张顺 / 独立复验 | | S02 Token 隔离与会话吊销 | Access/Refresh 类型与密钥隔离、轮换、重放处置、会话吊销 | auth/(JwtTokenProvider、AuthSessionService、AuthTokenHasher、JwtAuthenticationFilter、AuthController)、V42__auth_token_sessions.sql | Refresh 不能访问普通接口;Access 不能刷新;二次使用撤销当前会话;并发刷新最多成功一次;登出/注销完成使会话失效 | 张顺 / 独立复验 |
| S03 生产配置与简历隐私 | 历史种子账号治理、一次性管理员初始化、企业简历下载授权、Actuator 隔离 | auth/(AdminBootstrapRunner)、application/(CompanyResumeAccessPolicy、CompanyResumeService)、V43__disable_legacy_seed_accounts.sql、application.yml | V43 只禁用完全匹配原始指纹的三条种子账号;管理员初始化默认关闭且幂等;三种可见性 × 全部投递状态 × 跨企业/未认证企业矩阵;拒绝路径零文件读取;业务端口不提供指标 | 张顺 / 独立复验 |
## 3. 一期必须补齐的八个功能组 ## 3. 一期必须补齐的八个功能组

29
docs/privacy-settings-api.md

@ -99,4 +99,31 @@
## 6. 审计 ## 6. 审计
更新隐私设置会写入审计日志 `UPDATE_PRIVACY_SETTINGS`。审计中**不得**写入 `phone / email / password / jwt / resumeUrl` 等敏感值。 更新隐私设置会写入审计日志 `UPDATE_PRIVACY_SETTINGS`。审计中**不得**写入 `phone / email / password / jwt / resumeUrl` 等敏感值。
## S03-B:`resumeVisibility` 的企业侧执行边界
此前 `resumeVisibility` 只有存储与读写接口,**没有任何企业侧代码路径读取它**,等同于空转开关。
S03 起该设置在企业读取简历文件的唯一入口 `GET /company/candidates/{applicationId}/resume`
上真正生效,判定全部由失败关闭的 `CompanyResumeAccessPolicy` 完成。
### 可见性决策表
| 设置 | 企业条件 | 投递条件 | 结果 |
|---|---|---|---|
| 无设置记录 / `null` | 任意 | 任意 | 403(按 PRIVATE 处理) |
| `PRIVATE` | 任意 | 包括已投递、面试、录用 | 403 |
| `APPLIED_COMPANIES` | 岗位属于当前企业 | `SUBMITTED`/`VIEWED`/`INTERVIEW`/`HIRED` | 200 |
| `APPLIED_COMPANIES` | 岗位属于当前企业 | `WITHDRAWN`/`REJECTED` | 403 |
| `APPLIED_COMPANIES` | 其他企业 | 任意 | 403 |
| `VERIFIED_COMPANIES` | 当前企业 `verified=true` 且拥有该投递岗位 | 任意合法投递状态 | 200 |
| `VERIFIED_COMPANIES` | 未认证企业或其他企业 | 任意 | 403 |
### 重要变更
- **`APPLIED_COMPANIES` 与 `VERIFIED_COMPANIES` 现在都强制校验「投递所属岗位属于当前企业」。**
此前 `canCompanyViewResume` 不校验公司归属:`APPLIED_COMPANIES` 完全不使用 `companyId`,
`VERIFIED_COMPANIES` 只校验企业 `verified` —— 任何已认证企业都可凭任意 `applicationId` 查看简历。
- `VERIFIED_COMPANIES` **不是**「知道学生 ID 即可查看」:仍需合法的候选业务上下文。
- 隐私拒绝统一返回 `FORBIDDEN(2002)`;跨企业同样 403,不通过差异响应泄露归属。
- `PrivacySettingsService.canCompanyViewResume` 保留为委托方法,判定逻辑已收束到策略类,
不再保留可被误用的宽松实现。

39
docs/resume-api.md

@ -215,3 +215,42 @@ GET /resume-export-tasks/{taskId}/download
| 8204 | `RESUME_EXPORT_IN_PROGRESS` | 409 | 导出任务进行中,不可删除版本 | | 8204 | `RESUME_EXPORT_IN_PROGRESS` | 409 | 导出任务进行中,不可删除版本 |
| 8205 | `DUPLICATE_DEFAULT_RESUME` | 409 | 并发设置默认冲突 | | 8205 | `DUPLICATE_DEFAULT_RESUME` | 409 | 并发设置默认冲突 |
| 8206 | `RESUME_EXPORT_NOT_READY` | 409 | 导出任务未完成,不可下载 | | 8206 | `RESUME_EXPORT_NOT_READY` | 409 | 导出任务未完成,不可下载 |
## S03-B:企业下载候选人简历
### `GET /company/candidates/{applicationId}/resume`
新增的**唯一**企业侧简历读取入口。返回文件流,**不返回** `resumeUrl`、存储相对/绝对路径、
学生用户 ID、手机号或邮箱。
- 认证:`Authorization: Bearer <COMPANY 用户 Access Token>`
- 路径参数:`applicationId`(本企业岗位下的投递)
- 响应:二进制文件流;`Content-Type` 只取自上传时已校验的白名单
(`application/pdf`、`application/msword`、DOCX),否则回退 `application/octet-stream`
- 响应头:`Content-Disposition: attachment; filename*=UTF-8''<已净化文件名>`,
以及 `Cache-Control: no-store`(私有简历不进任何中间缓存)
- 文件名继续经过 `ResumeFilenameSanitizer`,拒绝路径分隔符、控制字符与响应头换行
### 错误语义
| 场景 | 结果 |
|---|---|
| 未登录 / 无有效会话 | 401 |
| 非 COMPANY 角色(STUDENT / ADMIN) | 403 |
| 当前用户没有企业资料 | 403 |
| 投递不存在 | 404 `APPLICATION_NOT_FOUND` |
| 跨企业投递 | 403 |
| 隐私设置不允许(含 PRIVATE / 无设置) | 403 `FORBIDDEN` |
| 授权通过但学生没有简历 | 404 `PROFILE_NOT_FOUND` |
### 授权与副作用
- 授权顺序:当前 COMPANY 用户 → 当前 Company → Application+Job → `Job.companyId` →
Student → privacy setting → profile/file。归属信息全部由服务端推导,
不接受客户端传入的 `companyId`/`studentUserId`。
- **拒绝路径不会调用存储读取**(测试以计数包装器验证为零次)。
- 该接口只读:不修改 `student_profiles`、`resume_versions`、投递、评分、排名、推荐、邀约、
消息、通知或风险表;不发送 RabbitMQ、短信或邮件。
- 学生本人的上传、下载、版本与导出接口语义完全不变。
- 企业候选人列表/详情 DTO **仍不含** `resumeUrl`。
- 不新增按 `studentUserId` 或 `candidateKey` 下载简历的入口;人才发现本轮不得直接下载简历。

167
docs/s03-independent-mysql-acceptance-20260915.md

@ -0,0 +1,167 @@
# S03「生产默认账号、简历隐私与 Actuator 隔离」独立代码复验及真实 MySQL/Docker 黑盒验收
日期:2026-09-15
分支:`feat/s03-production-privacy`
结论:**FAIL —— 已复现具体缺陷(阶段 I-1:业务端口 Actuator 路径非 404),S03 不可冻结;报告包含下一轮定向整改指令。**
## 1. Git 基线与工作区状态
- 当前分支 `feat/s03-production-privacy`;HEAD `f399060 feat: isolate and revoke authentication token sessions`。
- 暂存区为空;全部 S03 修改处于未提交(M/??)状态。
- `git diff --check` 通过;`git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` 为空(V1–V42 无改动)。
- 唯一新增迁移:`V43__disable_legacy_seed_accounts.sql`。
- 未执行 `git add -A`/commit/push/reset/restore/checkout/clean;未删除历史文件。
## 2. 独立代码审查(阶段 A)
| 项 | 结论 | 关键证据 |
|---|---|---|
| A1 V43 迁移安全 | PASS | 3 条 UPDATE(无 DELETE/DROP/TRUNCATE);不按 id 匹配;每条同时匹配原始手机号+邮箱+公开 bcrypt 哈希+主角色+`is_active=1`;只置 `is_active=0`;不碰 `user_roles`;指纹被改的账号不命中;V1–V42 未修改 |
| A2 管理员初始化装配 | PASS | 默认关闭 + 字面量 `"true"` 双重校验;手机号无默认值、非法即启动失败;已有活动 ADMIN 无副作用跳过;不设密码(`password_hash=NULL`)/不发 Token/不发短信/无公网接口;日志与异常文本固定且不含手机号(`AdminBootstrapRunner.ERROR_INVALID_PHONE/ERROR_PHONE_IN_USE`) |
| A3 事务与并发 | PASS | `JdbcTemplate + REQUIRES_NEW` 独立事务,users 与 user_roles 同事务回滚;刻意绕过 Hibernate 避免 `SqlExceptionHelper` 记录绑定手机号;异常被捕获只返回 `CONFLICT`,原始 cause 不传播;冲突后仅 `isValidBootstrapAdmin` 全条件匹配才幂等 |
| A4 简历授权失败关闭 | PASS | `CompanyResumeAccessPolicy`:归属全部服务端推导;无记录/PRIVATE 按 PRIVATE;APPLIED_COMPANIES 严格白名单(SUB/VIEWED/INTERVIEW/HIRED,区分大小写,未知/空白/null/WITHDRAWN/REJECTED 全拒);VERIFIED_COMPANIES 要求 `verified=true` 且岗位归属;授权失败发生在文件读取前 |
| A5 文件与响应安全 | PASS | 授权后读文件;`Content-Disposition attachment+UTF-8`、`Cache-Control: no-store`、Content-Type 白名单回退 octet-stream;响应不含 resumeUrl/storagePath/studentUserId |
| A6 Actuator 隔离(设计层面) | PASS* | 独立管理端口默认 127.0.0.1;仅暴露 health/info/metrics/prometheus;`show-details/components=never`;Prometheus 显式开启;不当 JWT 运维认证。*注:阶段 I-1 真实验收发现业务端口行为不达标,见第 12 节 |
| A7 副作用测试真实性 | PASS | `CompanyResumeDownloadIntegrationTest` 行级字段快照(如 users: id/phone/email/role/is_active/is_verified/updated_at),非 COUNT/SUM;含反证测试(单行 status 变化必须被发现)与 15 表字段覆盖断言 |
## 3. 自动化独立复验(阶段 B)
| 命令 | Tests | Failures | Errors | Skipped | 结果 |
|---|---:|---:|---:|---:|---|
| `mvn clean verify`(实测,非沿用历史) | 1834 | 0 | 0 | 0 | BUILD SUCCESS |
| `python docs\verify_openapi.py` | — | — | — | — | paths=176 operations=214,All checks passed |
## 4. Docker/MySQL 环境(阶段 C)
- Docker Engine 29.6.2;容器由负责人环境启动(本次仅 `docker start` 已存在容器,未重建、未删 volume)。
- MySQL 8.4.11(127.0.0.1:3307,库 `mydreamjob`);Redis PONG;RabbitMQ healthy。
- Flyway V1–V42 全部 success=1。
## 5. V43 真实迁移验收(阶段 D)
- 迁移前三个种子账号脱敏快照:role=ADMIN/STUDENT/COMPANY,均 `is_active=1`,指纹与 V1_1 原始值完全匹配,`user_roles` 各 1 条,关联业务数据 4(applications)/21(jobs)/2(profiles)/1(company)。
- 最新 JAR + dev(`ddl-auto=validate`)启动后 `flyway_schema_history`:`V43 disable legacy seed accounts success=1`,无失败迁移,V1–V42 校验和未变。
- 三个种子账号 `is_active` 1→0;users 行仍存在;`user_roles` 仍存在;关联业务数据未删除。
- 指纹偏差隔离夹具(7976/7977,改过手机号与哈希)在 V43 后仍 `is_active=1`,未误伤。
- 原种子账号(demo 口令)登录返回 400 且无法取得 Token(已禁用账号不能建立会话),达成"不能取得 Token"目标。
## 6. 管理员初始化配置矩阵(阶段 E,真实 MySQL)
| 用例 | 结果 | 实测证据 |
|---|---|---|
| E1 默认关闭 | PASS | 应用正常启动且未创建任何 ADMIN |
| E2 enabled=true+非法手机号 | PASS | 启动失败,固定文案 `admin bootstrap rejected: configured phone is invalid` |
| E3 无活动ADMIN+空闲手机号 | PASS | users 恰 1 行(role=ADMIN、active、verified、`password_hash=NULL`);`user_roles` 仅 ADMIN;无 Token/短信/通知;日志无手机号 |
| E4 重复启动同配置 | PASS | `skipped`;users 与 ADMIN 角色计数均不增 |
| E5 手机号被 STUDENT 占用 | PASS | 启动失败固定文案 `phone is already in use`;该 STUDENT 未被提权/复活/覆盖 |
| E6 真实双 JAR 并发同手机号 | PASS | 两个独立进程 + 屏障同时初始化:最终 users 恰 1 行、ADMIN 角色恰 1 条;一方创建、一方安全幂等跳过;无手机号泄漏;无 `SqlExceptionHelper` 唯一键日志 |
前置与后置:临时禁用 20 个历史验收 ADMIN(id 逐一记录)并以精确 UPDATE 恢复原状;E3/E6 新建的验收 ADMIN 置为 inactive,未删除。
## 7. 启动最新 JAR 与双端口(阶段 F)
- `Started MyDreamjobApplication`;无 Flyway/schema-validation 错误。
- 8080 与 8081 均监听;**8081 仅绑定 127.0.0.1**。
- `GET /api/v1/health` → 200/UP(仅最小状态,无 db/redis/rabbit/连接池字段)。
- 运行时 OpenAPI:paths=176,operations=214,与静态一致。
## 8. 企业简历真实 HTTP 权限矩阵(阶段 G / H)
夹具:3 家企业(认证/未认证/其他)、6 名学生(APPLIED_COMPANIES·SUBMITTED、PRIVATE、无隐私记录、VERIFIED_COMPANIES·未认证企业、APPLIED_COMPANIES·WITHDRAWN、VERIFIED_COMPANIES·已认证企业),真实简历文件进存储基目录,投递/隐私由服务端推导。
| 用例 | HTTP | 期望 | 结果 |
|---|---|---|---|
| 匿名 | 401 | 401 | PASS |
| STUDENT Token | 403/2002 | 403 | PASS |
| ADMIN Token | 403/2002 | 403 | PASS |
| 其他企业 | 403/2002 | 403 | PASS |
| 未认证企业(岗位不属于) | 403/2002 | 403 | PASS |
| 认证企业+APPLIED+SUBMITTED | 200 | 200 | PASS(下文字节/头断言) |
| PRIVATE 隐私 | 403/2002 | 403 | PASS |
| 无隐私记录(默认 PRIVATE) | 403/2002 | 403 | PASS |
| VERIFIED_COMPANIES+未认证企业 | 403/2002 | 403 | PASS |
| APPLIED_COMPANIES+WITHDRAWN | 403/2002 | 403 | PASS |
| VERIFIED_COMPANIES+已认证企业 | 200 | 200 | PASS(字节一致) |
| 投递不存在 | 404/8002 | 404 | PASS |
成功下载断言:文件字节与夹具逐字节一致(`RESUME-FIXTURE-A/F-20260915`,字节级比较 PASS);`Content-Disposition: attachment; filename="=?UTF-8?Q?...?="`(净化文件名);`Cache-Control: no-store`;Content-Type 走白名单;响应不含存储路径/resumeUrl/studentUserId/手机号/邮箱。
日志/审计扫描:未发现简历正文标记、本地路径、S03 测试手机号、密钥等禁止字段。
## 9. Actuator 双端口真实验收(阶段 I)
**管理端口 127.0.0.1:8081 — 全部 PASS**
- `/actuator/health` 200,JSON 仅含 `status`(最小化,无 components/db/redis/rabbit/diskSpace/hikari)。
- `/actuator/metrics` 200(含 names 数组);`/actuator/prometheus` 200(Prometheus 文本、含 `jvm_memory_used_bytes` 等稳定指标)。
- `/actuator/env`、`/beans`、`/configprops`、`/heapdump`、`/threaddump` 均不可读(401)。
- 端口仅绑定 127.0.0.1;prometheus 文本未发现密码/Token/Bearer 等敏感值。
**业务端口 8080 — FAIL(本轮关键缺陷)**
| 路径 | 匿名 | 带 STUDENT Token(有效) |
|---|---|---|
| `/actuator` | 401 | 500 |
| `/actuator/health` | 500 | 500 |
| `/actuator/metrics` | 500 | 500 |
| `/actuator/info` | 500 | 500 |
| `/actuator/prometheus` | 500 | 500 |
| `/actuator/env` | 401 | 500 |
| `/actuator/beans` | 401 | 500 |
任务书要求这些路径**无论匿名或带 Token 一律 404**;实测为 401/500。根因:`SecurityConfig` 对 `/actuator/health/**` 等显式 `permitAll`,但业务端口并不注册 Actuator 端点,请求落入 MVC 静态资源解析抛 `NoResourceFoundException`,被 `GlobalExceptionHandler` 通用 handler 映射为 500;未放行的 `/actuator`、`/actuator/env`、`/actuator/beans` 在匿名时被安全层拦为 401,带 Token 后同样 500。
同时,`ActuatorIsolationIntegrationTest.businessPortDoesNotExposeActuatorEndpoints` 断言这些路径为 404 且在 H2 测试环境下通过——**测试环境(H2、无独立管理端口随机化 etc.)与真实 MySQL + 完整 Security 配置的生产行为不一致,测试未能捕获 401/500 差异**,属测试真实性缺口。
## 10. 副作用(阶段 J)
下载成功、下载拒绝、下载 404 三类请求前后,对 15 张表(users、user_roles、student_profiles、resume_versions、applications、jobs、companies、auth_tokens、audit_logs、candidate_dimension_scores、notifications、messages、scoring_risk_events、scoring_risk_actions、talent_invitations)作行级全量快照对比:**完全一致,无任何变化**。PASS。
## 11. RabbitMQ(阶段 K)
4 个队列真实存在且 0 积压:`queue.sms.verification`、`queue.notification`、`queue.audit`、`queue.notification.dlq`(messages=0、ready=0、unacked=0)。管理员初始化与简历下载均未产生新增消息。PASS。
## 12. OpenAPI(阶段 L)
静态 `verify_openapi.py`:176 paths / 214 operations,全部通过(含 S03 简历下载契约、S02/S03 校验)。运行时 `/api/v1/v3/api-docs`:176/214 与静态一致。新端点仅 `GET /company/candidates/{applicationId}/resume`,无 requestBody、返回文件流;企业 DTO 不含 resumeUrl/storagePath/studentUserId/phone/email。PASS。
## 13. PASS / FAIL / SKIP 清单
- PASS:阶段 A(A1–A7);阶段 B(1834 测试、OpenAPI 静态);阶段 C;阶段 D(V43 真实迁移与种子禁用、不误伤、登录不可用);阶段 E(E1–E6);阶段 F;阶段 G/H(12/12、字节与头断言、日志脱敏);阶段 I-2 管理端口;阶段 J;阶段 K;阶段 L。
- **FAIL:阶段 I-1(业务端口 Actuator 路径 401/500 而非 404)+ 关联测试真实性缺口(ActuatorIsolationIntegrationTest 与生产行为不一致)。**
- SKIP:无(核心项全部执行;仅"更大规模容量压测"不在本轮范围,不构成阻断)。
## 14. 剩余风险
- 业务端口对 `/actuator/health` 等返回 500:属于错误响应泄漏(对探测者暴露服务器异常迹象),且不满足契约的 404 语义;修复后需补"业务端口带 Token 亦为 404"的真实 HTTP 覆盖。
- 登录接口对已禁用账号返回 400 而非 403/401(无法取得 Token 已满足;状态码语义差异建议与契约对齐)。
- 管理端口依赖网络隔离运维边界,生产中需确认部署层网络策略确实限制 8081 仅内网可访问。
## 15. 是否允许冻结 S03
**不允许。** 建议整改指令(下一轮定向修复,不修改本报告已执行的验收环境):
1. 业务端口对未注册 Actuator 路径统一返回 404:
- 在 `GlobalExceptionHandler` 增加 `NoResourceFoundException` 的精确映射(404),避免落入通用 500;或
- 移除 `SecurityConfig` 中业务端口对 `/actuator/**` 的 `permitAll` 并显式声明业务端口 Actuator 不可达(任选其一,保持管理端口行为不变)。
2. 增加真实 MySQL 环境下的 HTTP 断言:业务端口 `/actuator`、`/actuator/health`、`/actuator/metrics`、`/actuator/prometheus`、`/actuator/env`、`/actuator/beans` 在匿名与有效 STUDENT/COMPANY/ADMIN Token 下均为 404;并说明测试环境中为何出现 H2 404 而真实 MySQL 401/500 的差异,消除测试假绿。
3. 修复后在最新 JAR + 真实 MySQL 上重跑阶段 I(业务端口 404 矩阵 + 管理端口只读矩阵)与 1834 项全量回归。
本报告不包含数据库密码、JWT、验证码、完整手机号、邮箱、验证手机号或简历正文。验收脚本与日志保留在 `backend-infra/s03-*.ps1|.py|.log`,夹具数据按"本轮唯一前缀/精确主键"清理并记录;`audit_logs` 未做删除。
## 16. Actuator 404 增量复验结果(定向整改后)
上一节记录了初次独立验收的唯一阻断:业务端口 8080 上 `/actuator/**` 匿名/带 Token 行为为 401/500 而非任务书要求的 404,且 H2 测试与生产行为不一致。针对该阻断完成定向整改后,使用最新 `mvn clean verify` JAR 与真实 MySQL 8.4.11 复验:
1. **整改方式 —— SecurityConfig**:原对 `/actuator/health/**`、`/actuator/info`、`/actuator/metrics/**`、`/actuator/prometheus` 的四个 `permitAll` 收敛为单一 `.requestMatchers("/actuator/**").permitAll()`。目的**非暴露 Actuator**(Actuator 仍只注册在独立管理端口),而是让所有 `/actuator/**` 请求进入 MVC 的不存在资源处理,消除匿名请求先被认证层转 401 的分支;业务端口未新增任何 Actuator Controller 或端点映射。
2. **整改方式 —— GlobalExceptionHandler**:新增 `@ExceptionHandler(NoResourceFoundException.class)`,固定返回 HTTP 404、`code=404`、`message=not found`、`requestId`;不回显异常 message、请求路径、本地路径或堆栈;日志仅固定文案 + 归一化 requestId;未新增 ErrorCode。
3. **业务端口矩阵(真实 HTTP)**:14 条路径(`/actuator`、`/actuator/`、`/actuator/health`、`/actuator/health/liveness`、`/actuator/info`、`/actuator/metrics`、`/actuator/metrics/jvm.memory.used`、`/actuator/prometheus`、`/actuator/env`、`/actuator/beans`、`/actuator/configprops`、`/actuator/heapdump`、`/actuator/threaddump`、`/actuator/unknown`)× 6 种身份(匿名 / STUDENT / COMPANY / ADMIN / 非法 Token / Refresh Token)共 **84 个组合全部返回 404**,且响应递归扫描无任何异常类名、框架包名、路径、Token、手机号等泄漏。
4. **管理端口 127.0.0.1:8081**:health(200,最小 JSON,仅 `status`)/ info(200)/ metrics(200,含 names)/ prometheus(200,含稳定 JVM 指标)白名单端点正常;env / beans / configprops / heapdump / threaddump / unknown 全部 404(未暴露端点未被修复带成 200);8081 仅绑定 127.0.0.1。
5. **副作用**:复验期间日志无 `NoResourceFoundException` 堆栈、无 ERROR;RabbitMQ 4 个队列(sms.verification / notification / audit / notification.dlq)0 积压;数据库无任何改动。
6. **自动化**:定向(Actuator/Security/GlobalExceptionHandler)61/61;全量 `mvn clean verify` **1836/1836**(含新增 2 个 Actuator 复验测试),Failures=0、Errors=0、Skipped=0;OpenAPI 静态与运行时均为 **176 paths / 214 operations**,Actuator 不进入业务 OpenAPI。
7. **一致性**:V1–V42 未修改,V43 仍为唯一新增迁移;未新增 ErrorCode、公网业务接口、表或索引;应用进程已停止,当前未提交、未推送。
## 17. 最终结论
- 初次独立验收发现业务端口 Actuator 401/500 阻断;
- 完成定向整改(SecurityConfig 统一 `/actuator/**` 语义 + GlobalExceptionHandler 安全 404 映射 + 失败测试补齐);
- 最新 JAR + 真实 MySQL/Docker 增量复验全部通过。
**S03 独立代码复验和真实 MySQL/Docker 黑盒全部通过,可以进入精确暂存检查点。**

167
docs/s03-production-security-and-resume-privacy-design.md

@ -0,0 +1,167 @@
# S03 生产配置、简历隐私与 Actuator 隔离设计
状态:设计已确认,待按独立检查点实施。
## 1. 目标与边界
S03 只处理三类高风险问题:
1. 禁用仍保持原始状态的通用 Flyway 演示账号,并提供显式、一次性的管理员初始化能力;
2. 让 `resumeVisibility` 在企业读取简历文件的正式边界真正生效;
3. 将 Actuator 健康探针与指标端点隔离,防止普通业务用户读取运行指标。
S03 不修改 Access/Refresh Token 语义,不修改评分、排名、推荐、人才发现、邀约、Offer、申诉、消息撤回、简历上传与学生本人下载语义,不做性能重构,不进入 Q01/R01。
## 2. 已核实的现状
- `V1_1__seed_data.sql` 无环境隔离地插入已知 ADMIN、STUDENT、COMPANY 账号及公开口令哈希。
- V1–V42 已经执行,不允许修改校验和;S03 使用新的 V43 迁移。
- `student_privacy_settings.resume_visibility` 已支持 `PRIVATE`、`APPLIED_COMPANIES`、`VERIFIED_COMPANIES`,默认 `PRIVATE`。
- `PrivacySettingsService.canCompanyViewResume(...)` 没有生产调用方。
- 该方法的 `APPLIED_COMPANIES` 分支没有核对投递岗位归属 `companyId`,不能直接作为授权边界使用。
- 企业候选人 DTO 当前主动排除了 `resumeUrl`;当前没有企业下载简历文件的正式接口。
- `/actuator/health/**` 和 `/actuator/info` 公开;`metrics/prometheus` 被暴露后会落入普通 `authenticated()`,任何登录用户均可能访问。
## 3. S03-A 历史种子账号治理
### 3.1 V43 迁移原则
新增且仅新增:
`V43__disable_legacy_seed_accounts.sql`
V43 只能禁用仍与 V1_1 原始指纹完全一致的三条种子账号。匹配至少同时包含:
- 原始手机号;
- 原始邮箱;
- 原始公开 bcrypt 哈希;
- 原始主角色;
- `is_active=1`。
不得依赖固定 `id=1/2/3`。不得删除用户、角色、审计或业务数据,不得重置自增。不得修改已被运营人员改过手机号、邮箱、哈希或角色的账号。
命中后仅将 `users.is_active` 置为 `0`。保留记录与关联关系,用于历史数据完整性和审计追溯。由于认证签发链已拒绝 inactive 用户,已知账号不能再取得会话。
迁移必须幂等,并分别覆盖 ADMIN、STUDENT、COMPANY 三条原始种子。迁移报告只记录命中数量,不输出完整手机号、邮箱或哈希。
### 3.2 管理员一次性初始化
新增显式开关,默认关闭:
- `app.bootstrap.admin.enabled=${ADMIN_BOOTSTRAP_ENABLED:false}`
- `app.bootstrap.admin.phone=${ADMIN_BOOTSTRAP_PHONE:}`
- `app.bootstrap.admin.nickname=${ADMIN_BOOTSTRAP_NICKNAME:平台管理员}`
初始化器仅在开关严格为 `true` 时装配。普通启动不得隐式创建管理员。
初始化规则:
1. 手机号必须通过现有手机号格式校验;不得写日志、异常详情或审计 details。
2. 数据库已有活动 ADMIN 时安全跳过,不创建第二个管理员。
3. 数据库没有活动 ADMIN 且手机号不存在时,创建 `active=true`、`verified=true` 的 ADMIN,并同时写入 `users.role` 与 `user_roles`。
4. 手机号已属于 STUDENT/COMPANY、inactive 用户或角色信息不一致时启动失败,禁止自动提权或复活。
5. 相同配置重复启动必须幂等。
6. 初始化成功后应提示固定文本,部署方必须移除开关;日志不得包含手机号。
7. 多实例部署时只允许一个实例带初始化开关启动;数据库唯一手机号约束作为最后防线,冲突必须失败而不是吞掉。
不新增公网初始化接口,不接受请求参数创建 ADMIN,不设置默认密码。管理员仍使用正式短信认证流程;真实短信供应商未配置时不能以 Mock 绕过。
## 4. S03-B 简历隐私授权
### 4.1 唯一新增企业接口
新增:
`GET /company/candidates/{applicationId}/resume`
该接口返回文件流,不返回 `resumeUrl`、存储相对路径、绝对路径、学生用户 ID、手机号或邮箱。文件名必须继续经过 `ResumeFilenameSanitizer`。
除该接口外不新增按 `studentUserId`、`candidateKey` 或任意 URL 下载简历的入口。人才发现接口本轮不得直接下载简历。
### 4.2 授权顺序
必须在读取文件前依次完成:
1. 从 `SecurityContext` 取得当前用户,要求 COMPANY;
2. 定位当前用户拥有的 Company;
3. 查询 `applicationId` 及对应 Job;
4. 校验 Job 属于当前 Company;
5. 取得投递对应的 `studentUserId`;
6. 按隐私设置判定可见性;
7. 通过后才读取 `student_profiles.resume_url` 和文件内容。
跨企业投递统一返回既有 403 语义,不通过差异响应泄露归属。隐私拒绝使用 `FORBIDDEN(2002)`。不存在的投递保持既有 `APPLICATION_NOT_FOUND`;授权通过但学生没有简历时使用既有 `PROFILE_NOT_FOUND`,不新增 ErrorCode。
### 4.3 可见性决策表
| 设置 | 企业条件 | 投递条件 | 结果 |
|---|---|---|---|
| 无设置记录/null | 任意 | 任意 | 403,按 PRIVATE 处理 |
| PRIVATE | 任意 | 包括已投递、面试、录用 | 403 |
| APPLIED_COMPANIES | 岗位属于当前企业 | SUBMITTED/VIEWED/INTERVIEW/HIRED | 200 |
| APPLIED_COMPANIES | 岗位属于当前企业 | WITHDRAWN/REJECTED | 403 |
| APPLIED_COMPANIES | 其他企业 | 任意 | 403 |
| VERIFIED_COMPANIES | 当前企业 `verified=true` 且拥有该投递岗位 | 任意合法投递状态 | 200 |
| VERIFIED_COMPANIES | 未认证企业或其他企业 | 任意 | 403 |
`VERIFIED_COMPANIES` 不是“知道学生 ID 即可查看”。它仍要求合法的候选业务上下文,本轮该上下文限定为当前企业拥有的 `applicationId`。
### 4.4 代码结构
将现有布尔方法收束为失败关闭的授权组件,例如 `CompanyResumeAccessPolicy`。授权组件必须校验 company ownership,不能依赖 Controller 先验,也不能把客户端传入的 `studentUserId/companyId` 当成可信事实。
文件读取放在独立 Service 方法中。Controller 只解析身份、路径参数、构建安全响应头。任何拒绝路径都不得调用 `ResumeStorageService.read()`。
企业候选人列表与详情 DTO 保持不含 `resumeUrl`。学生本人上传、下载、版本、导出接口完全不变。
## 5. S03-C Actuator 隔离
### 5.1 运行边界
推荐配置:
- `management.server.port=${MANAGEMENT_SERVER_PORT:8081}`
- `management.server.address=${MANAGEMENT_SERVER_ADDRESS:127.0.0.1}`
- 仅暴露 `health,info,metrics,prometheus`;
- `health.show-details=never`;
- `health.show-components=never`。
生产默认只绑定回环地址。需要采集 Prometheus 时,由部署层通过受控网络或反向代理访问管理端口,不通过公网业务端口开放。
### 5.2 业务端口行为
业务端口不得提供 `/actuator/metrics` 或 `/actuator/prometheus`。普通 STUDENT、COMPANY、ADMIN 的业务 JWT 不能把管理指标变成公网资源。健康检查继续提供最小 `UP/DOWN` 信息,不返回数据库、Redis、RabbitMQ、磁盘路径或连接池细节。
本轮不新增运维账号体系,不把 ADMIN 等同于基础设施运维身份。
## 6. 数据与副作用
允许的数据库变化仅为:
- V43 对完全匹配的旧种子账号执行 `is_active: 1 -> 0`;
- 显式管理员初始化首次创建一条 ADMIN 用户和对应角色;
- 企业成功下载简历如需审计,只允许白名单动作与 `applicationId/companyId`,不得记录路径、文件名、学生 ID、联系方式或简历内容。
简历访问不得修改 `student_profiles`、`resume_versions`、投递、评分、排名、推荐、邀约、消息、通知或风险表,不发送 RabbitMQ、短信或邮件。
## 7. 契约与兼容性
- S02 基线为 175 paths / 213 operations。
- 新增一个企业简历下载接口后,预期为 176 paths / 214 operations;最终以运行时 OpenAPI 导出为准。
- 不修改既有响应字段,不把 `resumeUrl` 加回任何企业 DTO。
- 不新增 ErrorCode。
- V43 是唯一新增迁移,V1–V42 不得修改。
## 8. 验收门槛
必须同时通过:
- V43 在 H2 快速层和真实 MySQL 8 执行成功;
- 仅完全匹配的三条原始种子账号被禁用,改过任一指纹的账号不受影响;
- 管理员初始化关闭、成功、重复、已有 ADMIN、手机号冲突、并发冲突场景;
- 三种简历可见性及所有投递状态、跨企业、未认证企业、ADMIN/STUDENT/匿名权限矩阵;
- 拒绝路径零文件读取,企业 JSON/响应头无路径及个人信息泄漏;
- Actuator 业务端口与管理端口真实启动验证;
- 全量测试和 `mvn clean verify` 零失败、零错误、零跳过;
- 静态与运行时 OpenAPI 一致;
- 真实 MySQL/Docker 黑盒通过后才能冻结。

270
docs/s03-production-security-and-resume-privacy-implementation-instruction.md

@ -0,0 +1,270 @@
# S03 生产配置、简历隐私与 Actuator 隔离实施指令
项目根目录:`C:\Users\15642\Desktop\zhilai`
候选基线:S02 已完成独立代码复验、真实 MySQL/Docker 黑盒、精确提交并推送;功能分支提交为 `f399060edb2ff778322a4f021c93213f39a6f6bf`。
本轮必须先完整阅读 `docs/s03-production-security-and-resume-privacy-design.md`。该文档是 S03 权威口径;发生冲突时停止并报告,不得自行扩大语义。
## 一、开始前核查
执行:
```powershell
cd C:\Users\15642\Desktop\zhilai
git branch --show-current
git status --short
git diff --cached --name-only
git log -5 --oneline
git diff --check
```
要求:
- 从 S02 已提交基线创建独立分支 `feat/s03-production-privacy`;
- 切分支前确认暂存区和已跟踪工作区为空;
- 保留全部历史未跟踪脚本、日志、JSON、SQL、xlsx、`.env` 和验收目录;
- 不执行 `git reset/restore/checkout -- <path>/clean`;创建新分支仅允许 `git switch -c feat/s03-production-privacy`;
- 不执行 `git add -A`、commit、push;
- 实施阶段不操作 Docker,不执行真实 MySQL 黑盒;
- 搜索并完整阅读适用的 `AGENTS.md`,不存在则记录。
如已有修改与 S03 目标文件重叠,停止并报告。
## 二、必须完整阅读
迁移与模型:
- `V1__base_schema.sql`
- `V1_1__seed_data.sql`
- `V2__business_schema.sql`
- `V27__student_privacy_settings.sql`
- `V42__auth_token_sessions.sql`
- `User`、`UserRepository`、`Company`、`CompanyRepository`
- `Application`、`ApplicationRepository`、`Job`、`StudentProfile`
隐私与简历:
- `PrivacySettingsService`、`StudentPrivacySettings`、`ResumeVisibility`
- `ApplicationController`、`ApplicationService`、`CandidateDetailView`
- `ProfileController`、`ProfileService`
- `ResumeStorageService`、`LocalResumeStorageService`
- `ResumeFilenameSanitizer`、`ResumeFileValidator`
- 全部 `profile`、`resume`、`privacy`、`application` 测试
认证与运维:
- `SecurityConfig`、`CurrentUserAccessor`
- `AuthService`、`AuthSessionService`、`JwtAuthenticationFilter`
- `application.yml` 及所有 profile 配置
- `ErrorCode`、`GlobalExceptionHandler`
- `pom.xml` 与 Actuator 测试
文档:
- S03 权威设计文档
- `privacy-settings-api.md`
- `resume-api.md`
- `frontend-integration.md`
- `backend-development-roadmap.md`
- `phase1-scope-and-acceptance.md`
- `openapi-rc.json`
- `verify_openapi.py`
先执行搜索并记录所有企业侧简历暴露点:
```powershell
rg -n -i "resumeUrl|resume_url|download.*resume|resume.*download|ResumeStorageService\.read|canCompanyViewResume|studentUserId" mydreamjob-server/src/main/java
```
## 三、S03-A:禁用历史种子账号
新增且仅新增 `V43__disable_legacy_seed_accounts.sql`。
要求:
1. 不修改 V1–V42。
2. 不按固定 ID 匹配。
3. 每条 UPDATE 必须同时匹配 V1_1 中的原手机号、原邮箱、原 bcrypt 哈希、原角色、`is_active=1`。
4. ADMIN、STUDENT、COMPANY 三条分别处理。
5. 仅更新 `is_active=0`,不删除 users/user_roles,不修改历史业务表。
6. 改过手机号、邮箱、哈希或角色的同源账号不得被禁用。
7. 重复执行效果稳定。
新增迁移集成测试,至少覆盖:完整指纹命中、每个单字段偏差不命中、非固定 ID 也能命中、重复执行、关联数据保留、V1–V42 校验和不变。
## 四、S03-A:一次性管理员初始化
新增强类型配置和条件装配,例如:
- `AdminBootstrapProperties`
- `AdminBootstrapRunner`
- 必要的内部 Service
严格按设计文档执行:默认关闭;只有严格 `true` 才装配;手机号来自环境变量;无默认手机号;不打印手机号;不提供公网接口。
核心行为:
- 已有活动 ADMIN:无副作用跳过;
- 无活动 ADMIN + 手机号不存在:创建活动且已验证 ADMIN,`users.role` 与 `user_roles` 一致;
- 手机号已属于任何用户或角色状态异常:启动失败,禁止提权、复活或覆盖;
- 重复启动幂等;
- 多实例冲突安全失败;
- 不签发 Token、不发送短信、不写默认密码。
错误信息必须固定且不包含手机号、用户对象、SQL 或环境变量值。日志只能使用固定事件文本。
必须先写失败测试,覆盖:disabled、missing phone、invalid phone、existing active admin、new admin、duplicate run、existing student/company/inactive user、users/user_roles 一致性、日志脱敏、并发冲突。
## 五、S03-B:企业简历下载与隐私
新增唯一公网接口:
`GET /company/candidates/{applicationId}/resume`
推荐新增:
- `CompanyResumeAccessPolicy`
- `CompanyResumeService`
- 必要的文件响应 record/DTO
不得将文件访问逻辑塞入现有候选人 DTO,不得返回 `resumeUrl`。
授权必须在 Service/Policy 中完成,不可信任 Controller 传入的归属信息。按以下顺序:当前 COMPANY 用户 → 当前 Company → Application+Job → Job.companyId → Student → privacy setting → profile/file。
必须修复现有 `canCompanyViewResume` 的公司归属缺失:`APPLIED_COMPANIES` 不能只校验学生和状态;必须证明 application 对应 Job 属于当前 company。允许替换该方法或让它委托给新 Policy,但不得保留一个可被误用的宽松生产方法。
语义严格使用设计文档决策表:
- 默认/null 与 PRIVATE:全部企业 403;
- APPLIED_COMPANIES:仅本企业有效投递,WITHDRAWN/REJECTED 拒绝;
- VERIFIED_COMPANIES:企业 verified=true 且拥有 application 的岗位,状态不额外限制;
- 跨企业、未认证、错误角色均拒绝;
- 人才发现本轮不能借 candidateKey 下载简历。
响应要求:
- 使用安全 `Content-Disposition`;
- content type 只能来自已验证的白名单;
- 不返回内部路径、studentUserId、手机号、邮箱;
- 拒绝路径不得调用 storage `read`;
- 文件不存在安全失败,不回显路径;
- 不缓存私有文件,至少返回 `Cache-Control: no-store`;
- 不把简历正文写入日志、审计或异常。
权限测试必须使用真实 SecurityFilterChain/MockMvc,并覆盖 COMPANY、其他企业、未认证企业、STUDENT、ADMIN、匿名。集成测试必须使用真实 H2 Repository 和真实本地临时存储,不得 Mock 掉被验证的授权链。
对所有状态 `SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN` 分别验证三种 visibility。验证企业候选人列表/详情仍不含 `resumeUrl`。
## 六、S03-C:Actuator 隔离
按设计新增:
```yaml
management:
server:
port: ${MANAGEMENT_SERVER_PORT:8081}
address: ${MANAGEMENT_SERVER_ADDRESS:127.0.0.1}
```
并将 health details/components 设为 `never`。不要使用业务 JWT 角色代替运维网络隔离,不新增运维公网接口。
必须验证:
- 业务端口无法访问 metrics/prometheus;
- 管理端口只绑定配置地址;
- health 仅返回最小状态,不含 DB/Redis/RabbitMQ/磁盘/连接池详情;
- metrics/prometheus 只在管理端口存在;
- STUDENT/COMPANY/ADMIN Token 不会使业务端口指标可见;
- dev/test/integration-test 不发生随机端口冲突。
若 Spring Boot 独立 management context 与当前 SecurityFilterChain 行为不一致,先新增真实上下文失败测试,再做最小配置;不得通过重新公开指标绕过。
## 七、数据与副作用测试
前后保存完整摘要,至少覆盖:
- users、user_roles;
- student_profiles、resume_versions;
- applications、jobs、companies;
- auth_tokens、audit_logs;
- candidate_dimension_scores;
- notifications、messages;
- scoring_risk_events/actions;
- talent_invitations。
企业下载只允许读取;不得修改上述业务表,不发送 RabbitMQ、短信、邮件或通知。若记录下载审计,字段必须为固定白名单且不得包含路径、文件名、学生 ID 或内容。
## 八、OpenAPI 与文档
从运行时契约更新 `docs/openapi-rc.json`,不得手工伪造。
更新:
- `privacy-settings-api.md`
- `resume-api.md`
- `frontend-integration.md`
- `backend-development-roadmap.md`
- `phase1-scope-and-acceptance.md`
- `verify_openapi.py`
校验器必须递归断言:
- 只有一个新增路径/operation;
- 下载接口无 requestBody;
- 企业候选人 DTO 不含 `resumeUrl/studentUserId/phone/email/storagePath`;
- 不存在按 studentUserId/candidateKey 的简历下载路径;
- Actuator 不进入业务 OpenAPI;
- 预期基线为 176 paths / 214 operations,最终以运行时生成结果为准。
## 九、验证顺序
先运行 S03 新增定向测试,再运行:
```powershell
cd C:\Users\15642\Desktop\zhilai\mydreamjob-server
mvn -Dtest="*AdminBootstrap*Test,*LegacySeed*Test,*Privacy*Test,*Resume*Test,*CompanyCandidate*Test,*Actuator*Test" test
mvn -Dtest="*Auth*Test,*Security*Test,*Application*Test,*Company*Test,*Privacy*Test,*Resume*Test" test
mvn test
mvn clean verify
python ..\docs\verify_openapi.py
```
最后执行:
```powershell
cd C:\Users\15642\Desktop\zhilai
git diff --check
git status --short
git diff --cached --name-only
git diff --name-only -- mydreamjob-server/src/main/resources/db/migration
```
必须满足 Failures=0、Errors=0、Skipped=0、clean verify 成功、静态 OpenAPI 校验通过、暂存区为空、V43 为唯一迁移、未新增 ErrorCode。
## 十、实施结束状态
实施结束立即停止,不操作 Docker,不执行真实 MySQL 黑盒,不暂存、不提交、不推送,不进入 Q01。
最终报告必须列出:
1. V43 精确匹配条件和各账号命中结果;
2. 被修改过的种子账号不受影响证据;
3. 管理员初始化配置、幂等、冲突与并发结果;
4. 是否打印手机号或创建默认密码;
5. 企业简历权限完整决策矩阵;
6. `APPLIED_COMPANIES` 公司归属缺陷修复方式;
7. 拒绝路径文件读取次数;
8. 完整响应与响应头脱敏结果;
9. Actuator 业务端口/管理端口结果;
10. 数据库副作用与 RabbitMQ 结果;
11. 专项、关联、全量测试数及 Failures/Errors/Skipped;
12. clean verify 与 OpenAPI 结果;
13. 精确修改文件和 Git 状态;
14. 是否新增 Flyway、表、索引、ErrorCode、路径;
15. 明确未执行 git add -A、commit、push、Docker/MySQL 黑盒;
16. 明确真实 MySQL/Docker 黑盒仍待独立验收。
最终状态只能写:
“S03 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收。”

34
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) print('paths:', len(d['paths']))
assert len(d['paths']) == 175, f"Expected 175 paths, got {len(d['paths'])}" assert len(d['paths']) == 176, f"Expected 176 paths, got {len(d['paths'])}"
paths = d['paths'] paths = d['paths']
@ -632,10 +632,38 @@ assert _auth_paths == {
}, f'Unexpected /auth paths: {sorted(_auth_paths)}' }, f'Unexpected /auth paths: {sorted(_auth_paths)}'
print(' S02 refresh/logout operations and isolation schemas validated') print(' S02 refresh/logout operations and isolation schemas validated')
# 统计 operation 总数并校验 V43 + N07 + S02 计数 213 # ── S03:唯一新增的企业简历下载接口 ────────────────────────────────
_download_path = '/company/candidates/{applicationId}/resume'
assert _download_path in paths, 'Missing company resume download endpoint'
_download_get = paths[_download_path]['get']
assert 'requestBody' not in _download_get, 'Resume download must not declare requestBody'
assert set(paths[_download_path].keys()) == {'get'}, 'Resume download must be GET only'
# 不得存在按 studentUserId / candidateKey 下载简历的入口
for _p in paths:
_lower = _p.lower()
if 'resume' in _lower or 'download' in _lower:
assert 'studentuserid' not in _lower, f'Resume path must not key on studentUserId: {_p}'
assert 'candidatekey' not in _lower, f'Resume path must not key on candidateKey: {_p}'
# 企业候选人 DTO 不得回带 resumeUrl / studentUserId / phone / email / storagePath
for _dto in ('CandidateDetailView', 'CandidateItemView'):
_node = d['components']['schemas'].get(_dto)
if _node is None:
continue
_text = json.dumps(resolve_schema(_node), ensure_ascii=False)
for _forbidden in ('resumeUrl', 'studentUserId', 'phone', 'email', 'storagePath'):
assert _forbidden not in _text, f'{_dto} exposes forbidden field {_forbidden}'
# Actuator 不得进入业务 OpenAPI
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 计数 214
operations = sum(len(methods) for methods in d['paths'].values()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) print('operations:', operations)
assert operations == 213, f"Expected 213 operations, got {operations}" assert operations == 214, f"Expected 214 operations, got {operations}"
print(" Offer advance endpoints all present") print(" Offer advance endpoints all present")
print(" 26 operations all present") print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)") print(" Account deletion endpoints present (3 operations)")

5
mydreamjob-server/pom.xml

@ -54,6 +54,11 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId> <artifactId>spring-boot-starter-actuator</artifactId>
</dependency> </dependency>
<!-- S03-C:Actuator 暴露 prometheus 端点所需;缺少该依赖时 /actuator/prometheus 不存在。 -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- Flyway --> <!-- Flyway -->
<dependency> <dependency>

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

@ -4,6 +4,7 @@ import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.application.dto.CandidateDetailView; import com.mydreamjob.application.dto.CandidateDetailView;
import com.mydreamjob.application.dto.CompanyResumeFile;
import com.mydreamjob.application.dto.CandidateListResponse; import com.mydreamjob.application.dto.CandidateListResponse;
import com.mydreamjob.application.dto.ApplicationCreateRequest; import com.mydreamjob.application.dto.ApplicationCreateRequest;
import com.mydreamjob.application.dto.ApplicationStatusRequest; import com.mydreamjob.application.dto.ApplicationStatusRequest;
@ -15,11 +16,15 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -34,6 +39,7 @@ public class ApplicationController {
private final ApplicationService applicationService; private final ApplicationService applicationService;
private final CompanyService companyService; private final CompanyService companyService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final CompanyResumeService companyResumeService;
private static final Set<String> ALLOWED_STATUSES = Set.of( private static final Set<String> ALLOWED_STATUSES = Set.of(
"SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); "SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN");
@ -204,6 +210,29 @@ public class ApplicationController {
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
} }
@Operation(summary = "企业下载候选人简历",
description = "按候选人隐私设置授权后返回简历文件流;不返回存储路径、学生 ID 或联系方式")
@GetMapping("/company/candidates/{applicationId}/resume")
public ResponseEntity<byte[]> downloadCandidateResume(
@PathVariable Long applicationId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
assertCompanyRole(userId);
// 授权全部在 Service/Policy 内完成:这里不传递任何归属信息,只传 applicationId。
CompanyResumeFile file = companyResumeService.load(userId, applicationId);
ContentDisposition disposition = ContentDisposition.attachment()
.filename(file.filename(), StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(file.contentType()))
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
// 私有简历不进任何中间缓存。
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.body(file.content());
}
// ── 辅助 ────────────────────────────────────────────────── // ── 辅助 ──────────────────────────────────────────────────
private void assertCompanyRole(Long userId) { private void assertCompanyRole(Long userId) {

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

@ -0,0 +1,132 @@
package com.mydreamjob.application;
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.job.Job;
import com.mydreamjob.job.JobRepository;
import com.mydreamjob.privacy.ResumeVisibility;
import com.mydreamjob.privacy.StudentPrivacySettings;
import com.mydreamjob.privacy.StudentPrivacySettingsRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Set;
/**
* S03-B 企业简历访问授权(失败关闭)。
*
* <p>授权顺序严格按设计 §4.2:当前 COMPANY 用户 → 当前 Company → Application+Job →
* Job.companyId → Student → privacy setting。归属信息全部由服务端推导,
* <strong>不信任任何客户端传入的 companyId / studentUserId</strong>。</p>
*
* <p>决策表(设计 §4.3):</p>
* <ul>
* <li>无设置记录 / null / PRIVATE:拒绝(按 PRIVATE 处理)</li>
* <li>APPLIED_COMPANIES:岗位必须属于当前企业,且投递状态为
* SUBMITTED/VIEWED/INTERVIEW/HIRED;WITHDRAWN/REJECTED 拒绝</li>
* <li>VERIFIED_COMPANIES:当前企业 {@code verified=true} <strong>且</strong>拥有该投递岗位;
* 投递状态不额外限制</li>
* </ul>
*
* <p>本类修复了此前 {@code PrivacySettingsService.canCompanyViewResume} 只校验学生与状态、
* 不校验企业归属的缺陷。</p>
*/
@Component
@RequiredArgsConstructor
public class CompanyResumeAccessPolicy {
/**
* {@code APPLIED_COMPANIES} 允许的投递状态:**严格白名单**。
*
* <p>不得改为「不是拒绝状态就允许」的黑名单逻辑 —— 那会让 {@code null}、空白、
* 未知状态或大小写变体(如 {@code "submitted"})失败开放。状态比较区分大小写。</p>
*/
static final Set<String> APPLIED_COMPANY_VISIBLE_STATUSES =
Set.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED");
private final CompanyRepository companyRepository;
private final ApplicationRepository applicationRepository;
private final JobRepository jobRepository;
private final StudentPrivacySettingsRepository privacyRepository;
/** 授权通过后返回候选人用户 ID;这是后续读取简历的唯一依据。 */
public record Decision(Long studentUserId) {
}
/**
* 企业下载接口使用的授权入口:仅凭当前登录公司用户与路径中的 applicationId 完成全部判定。
*
* @throws BusinessException 未拥有企业资料 / 跨企业 / 隐私拒绝 → {@code FORBIDDEN(2002)};
* 投递不存在 → {@code APPLICATION_NOT_FOUND(8002)}
*/
public Decision authorizeForDownload(Long companyUserId, Long applicationId) {
if (companyUserId == null || applicationId == null) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
Company company = companyRepository.findByOwnerUserId(companyUserId).orElse(null);
if (company == null) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
Application application = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
if (!isVisibleToCompanyInternal(application, company.getId())) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return new Decision(application.getStudentUserId());
}
/**
* 既有布尔语义的授权判定(供 {@code PrivacySettingsService} 委托)。
*
* <p>调用方提供的 {@code studentUserId} 只用于一致性校验:必须与投递记录一致,
* 否则视为不可信并拒绝。</p>
*/
public boolean isVisible(Long studentUserId, Long companyId, Long applicationId) {
if (studentUserId == null || companyId == null || applicationId == null) {
return false;
}
Application application = applicationRepository.findById(applicationId).orElse(null);
if (application == null || !studentUserId.equals(application.getStudentUserId())) {
return false;
}
return isVisibleToCompanyInternal(application, companyId);
}
/**
* 核心判定:投递所属岗位必须属于该企业,再按学生隐私设置决定可见性。
*/
private boolean isVisibleToCompanyInternal(Application application, Long companyId) {
Job job = jobRepository.findById(application.getJobId()).orElse(null);
if (job == null || !companyId.equals(job.getCompanyId())) {
// 岗位不存在或不属于当前企业:一律拒绝(含跨企业投递)。
return false;
}
ResumeVisibility visibility = visibilityOf(application.getStudentUserId());
switch (visibility) {
case APPLIED_COMPANIES:
// 白名单判定(失败关闭):null / 空白 / 未知 / 大小写变体一律拒绝。
// 先做 null 判断,避免 Set.of(...).contains(null) 抛 NPE。
return application.getStatus() != null
&& APPLIED_COMPANY_VISIBLE_STATUSES.contains(application.getStatus());
case VERIFIED_COMPANIES:
Company company = companyRepository.findById(companyId).orElse(null);
return company != null && Boolean.TRUE.equals(company.getVerified());
case PRIVATE:
default:
// 无设置记录、null 或 PRIVATE:按 PRIVATE 处理。
return false;
}
}
private ResumeVisibility visibilityOf(Long studentUserId) {
StudentPrivacySettings settings = privacyRepository.findByStudentUserId(studentUserId).orElse(null);
if (settings == null || settings.getResumeVisibility() == null) {
return ResumeVisibility.PRIVATE;
}
return settings.getResumeVisibility();
}
}

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

@ -0,0 +1,72 @@
package com.mydreamjob.application;
import com.mydreamjob.application.dto.CompanyResumeFile;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.profile.StudentProfile;
import com.mydreamjob.profile.StudentProfileRepository;
import com.mydreamjob.profile.storage.ResumeFilenameSanitizer;
import com.mydreamjob.profile.storage.ResumeStorageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Set;
/**
* S03-B 企业读取候选人简历文件。
*
* <p>本类只负责「授权已通过之后」的文件读取与响应构建:
* 授权全部由 {@link CompanyResumeAccessPolicy} 完成,**任何拒绝路径都不会走到
* {@link ResumeStorageService#read}</strong>。</p>
*
* <p>简历正文、存储路径、原始文件名与学生标识都不写入日志、审计或异常。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CompanyResumeService {
/** Content-Type 白名单:只能来自上传时已校验的三种简历类型。 */
static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document");
static final String FALLBACK_CONTENT_TYPE = "application/octet-stream";
private final CompanyResumeAccessPolicy accessPolicy;
private final StudentProfileRepository profileRepository;
private final ResumeStorageService resumeStorageService;
/**
* 读取该投递对应候选人的简历。
*
* @throws BusinessException 授权失败({@code FORBIDDEN} / {@code APPLICATION_NOT_FOUND}),
* 或授权通过但学生没有简历({@code PROFILE_NOT_FOUND})
*/
@Transactional(readOnly = true)
public CompanyResumeFile load(Long companyUserId, Long applicationId) {
// 先授权,后读文件:拒绝路径绝不触碰存储。
CompanyResumeAccessPolicy.Decision decision =
accessPolicy.authorizeForDownload(companyUserId, applicationId);
StudentProfile profile = profileRepository.findByUserId(decision.studentUserId())
.orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND));
String storagePath = profile.getResumeUrl();
if (storagePath == null || storagePath.isBlank()) {
throw new BusinessException(ErrorCode.PROFILE_NOT_FOUND);
}
byte[] content = resumeStorageService.read(storagePath);
String filename = ResumeFilenameSanitizer.forDownload(profile.getResumeOriginalName(), "resume");
String contentType = resolveContentType(profile.getResumeContentType());
return new CompanyResumeFile(content, filename, contentType);
}
private String resolveContentType(String stored) {
return (stored != null && ALLOWED_CONTENT_TYPES.contains(stored)) ? stored : FALLBACK_CONTENT_TYPE;
}
}

10
mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CompanyResumeFile.java

@ -0,0 +1,10 @@
package com.mydreamjob.application.dto;
/**
* S03-B 企业简历下载的内部响应载体。
*
* <p>只携带渲染文件响应所必需的三个字段:文件内容、已净化的下载文件名、白名单内的
* Content-Type。**不包含**存储路径、学生用户 ID、手机号、邮箱或简历正文文本。</p>
*/
public record CompanyResumeFile(byte[] content, String filename, String contentType) {
}

105
mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapCreator.java

@ -0,0 +1,105 @@
package com.mydreamjob.auth;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.List;
import java.util.Optional;
/**
* S03 定向整改:管理员账号创建的**独立事务组件**。
*
* <p>为什么不用 JPA/Hibernate:并发初始化时数据库唯一约束会成为正常并发控制手段,
* 而 Hibernate 的 {@code SqlExceptionHelper} 会在业务代码捕获异常**之前**以 ERROR 级别
* 记录整条语句与绑定值 —— 管理员手机号因此进入生产可见日志。</p>
*
* <p>本组件改用 {@link JdbcTemplate} 的参数化语句:</p>
* <ul>
* <li>冲突在 JDBC 层被捕获,**不经过** Hibernate 日志体系;</li>
* <li>原始数据库异常与 message/cause **一律丢弃**,对外只返回结果码;</li>
* <li>{@code users} 与 {@code user_roles} 在**同一事务**内提交 ——
* 角色写入失败时用户创建整体回滚;</li>
* <li>不打印 SQL、手机号或绑定参数。</li>
* </ul>
*/
@Component
public class AdminBootstrapCreator {
/** 创建结果:成功创建 / 数据库唯一约束冲突。 */
public enum CreateResult {
CREATED,
CONFLICT
}
/** 引导账号的最小状态视图(不含任何敏感字段输出)。 */
public record BootstrapUser(Long id, String role, boolean active, boolean verified,
boolean passwordAbsent, List<String> roles) {
}
private final JdbcTemplate jdbcTemplate;
private final TransactionTemplate transactionTemplate;
/**
* 使用**独立 REQUIRES_NEW 事务**:冲突必须只回滚本次创建,不得把调用方的外层事务
* 标记为 rollback-only(否则调用方提交时会抛 {@code UnexpectedRollbackException})。
*/
public AdminBootstrapCreator(JdbcTemplate jdbcTemplate,
org.springframework.transaction.PlatformTransactionManager transactionManager) {
this.jdbcTemplate = jdbcTemplate;
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.transactionTemplate.setPropagationBehavior(
org.springframework.transaction.TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
/**
* 在同一事务内写入 {@code users} 与 {@code user_roles}。
*
* @return {@link CreateResult#CREATED} 或 {@link CreateResult#CONFLICT};
* 冲突时不抛出任何携带原始数据库信息的异常
*/
public CreateResult createAdmin(String phone, String nickname) {
try {
transactionTemplate.executeWithoutResult(status -> {
jdbcTemplate.update(
"INSERT INTO users (phone, nickname, password_hash, role, is_active, is_verified, "
+ "created_at, updated_at) VALUES (?, ?, NULL, 'ADMIN', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)",
phone, nickname);
Long userId = jdbcTemplate.queryForObject(
"SELECT id FROM users WHERE phone = ?", Long.class, phone);
jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, 'ADMIN')", userId);
});
return CreateResult.CREATED;
} catch (DataAccessException e) {
// 唯一约束冲突(并发初始化)、角色写入失败或任何 JDBC 层失败:事务已整体回滚。
// 刻意**丢弃**原始异常:它的 message 可能包含 SQL 与绑定值(例如手机号)。
// 调用方据此重新查询,决定是「幂等成功」还是抛出不带 cause 的固定安全异常。
return CreateResult.CONFLICT;
}
}
/** 按手机号读取引导所需的最小状态;只读、参数化,不经过 Hibernate。 */
public Optional<BootstrapUser> findByPhone(String phone) {
List<BootstrapUser> rows = jdbcTemplate.query(
"SELECT id, role, is_active, is_verified, password_hash FROM users WHERE phone = ?",
(rs, i) -> new BootstrapUser(
rs.getLong("id"),
rs.getString("role"),
rs.getInt("is_active") == 1,
rs.getInt("is_verified") == 1,
rs.getString("password_hash") == null,
List.of()),
phone);
if (rows.isEmpty()) {
return Optional.empty();
}
BootstrapUser base = rows.get(0);
List<String> roles = jdbcTemplate.queryForList(
"SELECT role FROM user_roles WHERE user_id = ?", String.class, base.id());
return Optional.of(new BootstrapUser(base.id(), base.role(), base.active(),
base.verified(), base.passwordAbsent(), roles));
}
/** 是否存在活动 ADMIN(不涉及手机号,可用 JPA 仓储)。 */
}

117
mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapRunner.java

@ -0,0 +1,117 @@
package com.mydreamjob.auth;
import com.mydreamjob.config.AdminBootstrapProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
/**
* S03-A 一次性管理员初始化(显式开关,默认关闭)。
*
* <p>仅在 {@code app.bootstrap.admin.enabled} 为字面量 {@code "true"} 时装配;
* 本类还会再校验一次字面量,确保大小写变体不会误启用。</p>
*
* <p>规则(设计 §3.2):</p>
* <ol>
* <li>手机号必须通过既有手机号格式校验;不写日志、异常详情或审计 details;</li>
* <li>已有活动 ADMIN → 无副作用跳过;</li>
* <li>无活动 ADMIN 且手机号未被占用 → 创建 active=true / verified=true 的 ADMIN,
* 同时写入 {@code users.role} 与 {@code user_roles};</li>
* <li>手机号已属于任何用户(任意角色 / inactive / 角色不一致)→ 启动失败,
* 禁止提权、复活或覆盖;</li>
* <li>相同配置重复启动幂等;</li>
* <li>多实例并发:数据库唯一手机号约束是最后防线,冲突直接失败,不吞掉。</li>
* </ol>
*
* <p>不签发 Token、不发送短信、不设置默认密码、不提供公网接口。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "app.bootstrap.admin.enabled", havingValue = "true")
public class AdminBootstrapRunner implements ApplicationRunner {
/** 与登录链路一致的手机号格式:11 位数字。 */
private static final Pattern PHONE_PATTERN = Pattern.compile("[0-9]{11}");
/** 严格字面量:只有 "true" 才算启用。 */
private static final String STRICTLY_ENABLED = "true";
/** 固定安全文案:不包含手机号、用户对象、SQL 或环境变量值。 */
static final String ERROR_INVALID_PHONE = "admin bootstrap rejected: configured phone is invalid";
static final String ERROR_PHONE_IN_USE = "admin bootstrap rejected: configured phone is already in use";
private final AdminBootstrapProperties properties;
private final UserRepository userRepository;
/** S03 整改:创建走独立的 JdbcTemplate 事务组件,避免 Hibernate 记录绑定手机号。 */
private final AdminBootstrapCreator creator;
@Override
public void run(ApplicationArguments args) {
// 兜底的字面量校验:即使 @ConditionalOnProperty 的匹配更宽松,也不允许非 "true" 生效。
if (!STRICTLY_ENABLED.equals(properties.getEnabled())) {
return;
}
String phone = properties.getPhone();
if (phone == null || !PHONE_PATTERN.matcher(phone.trim()).matches()) {
throw new IllegalStateException(ERROR_INVALID_PHONE);
}
phone = phone.trim();
// 已有活动 ADMIN:无副作用跳过,绝不创建第二个管理员。
if (userRepository.countByRoleAndActiveTrue(User.Role.ADMIN) > 0) {
log.info("admin bootstrap skipped: an active administrator already exists");
return;
}
// 手机号已被任何用户占用(含 inactive、STUDENT、COMPANY、角色不一致)?
Optional<AdminBootstrapCreator.BootstrapUser> existing = creator.findByPhone(phone);
if (existing.isPresent()) {
// 方案 A:仅当现存账号正是本次初始化配置要求的合法 ADMIN 时幂等成功;
// 普通用户 / inactive / 角色不一致一律启动失败,绝不提权、复活或覆盖。
if (isValidBootstrapAdmin(existing.get())) {
log.info("admin bootstrap skipped: a matching administrator already exists");
return;
}
throw new IllegalStateException(ERROR_PHONE_IN_USE);
}
AdminBootstrapCreator.CreateResult result = creator.createAdmin(phone, properties.getNickname());
if (result == AdminBootstrapCreator.CreateResult.CONFLICT) {
// 并发初始化:另一个实例刚创建了同一手机号。按方案 A 重新判定:
// 只有现存账号完全合法才幂等成功,否则一律失败。
if (creator.findByPhone(phone).filter(this::isValidBootstrapAdmin).isPresent()) {
log.info("admin bootstrap skipped: a matching administrator already exists");
return;
}
throw new IllegalStateException(ERROR_PHONE_IN_USE);
}
// 固定事件文本,不含手机号;部署方初始化后必须移除开关。
log.info("admin bootstrap created an administrator; remove the bootstrap switch now");
}
/**
* 方案 A 的幂等判据:现存账号必须**恰好**是本次初始化所要求的合法管理员。
*
* <p>要求角色为 ADMIN、active、verified、无默认口令、{@code user_roles} 含 ADMIN,
* 且不含 STUDENT/COMPANY 等附加角色。任一不满足即视为「手机号被占用」,启动失败。</p>
*/
private boolean isValidBootstrapAdmin(AdminBootstrapCreator.BootstrapUser user) {
return User.Role.ADMIN.name().equals(user.role())
&& user.active()
&& user.verified()
&& user.passwordAbsent()
&& user.roles().contains(User.Role.ADMIN.name())
&& !user.roles().contains(User.Role.STUDENT.name())
&& !user.roles().contains(User.Role.COMPANY.name());
}
}

12
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java

@ -16,6 +16,7 @@ import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@ -296,6 +297,17 @@ public class GlobalExceptionHandler {
.body(ApiResponse.forbidden("access denied", getRequestId(request))); .body(ApiResponse.forbidden("access denied", getRequestId(request)));
} }
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNoResourceFound(
NoResourceFoundException ex, HttpServletRequest request) {
// S03-C 整改:业务端口 /actuator/** 等不存在资源统一返回 404。
// 只记录固定文案与归一化 requestId,不回显异常 message、资源路径或堆栈。
log.warn("No resource found: requestId={}", getRequestId(request));
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error(404, "not found", getRequestId(request)));
}
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGenericException( public ResponseEntity<ApiResponse<Void>> handleGenericException(
Exception ex, HttpServletRequest request) { Exception ex, HttpServletRequest request) {

28
mydreamjob-server/src/main/java/com/mydreamjob/config/AdminBootstrapProperties.java

@ -0,0 +1,28 @@
package com.mydreamjob.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* S03-A 一次性管理员初始化配置。
*
* <p>默认关闭;只有显式把开关设为字面量 {@code "true"} 才会装配初始化器。
* 手机号只能来自环境变量,**没有默认值**,因此不会误建管理员。</p>
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "app.bootstrap.admin")
public class AdminBootstrapProperties {
/** 是否启用一次性管理员初始化(严格 true 才生效)。 */
private String enabled = "false";
/** 管理员手机号;无默认值,必须由部署方显式提供。 */
private String phone = "";
/** 管理员昵称。 */
private String nickname = "平台管理员";
}

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

@ -60,9 +60,14 @@ public class SecurityConfig {
"{\"code\":403,\"message\":\"access denied\",\"timestamp\":\"\"}"); "{\"code\":403,\"message\":\"access denied\",\"timestamp\":\"\"}");
})) }))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// Actuator 健康检查 // S03-C 整改:业务端口统一放行 /actuator/**。
.requestMatchers("/actuator/health/**").permitAll() // Actuator 端点仍只注册在独立管理端口(默认 127.0.0.1),业务端口上
.requestMatchers("/actuator/info").permitAll() // /actuator/** 全部落入 MVC 的不存在资源处理,由 GlobalExceptionHandler 的
// NoResourceFoundException 精确映射为 404 —— 避免匿名请求先被认证层
// 拦截成 401(未放行路径)或落入通用异常映射成 500(放行路径)。
// permitAll 不代表暴露指标,只保证所有 /actuator/** 都走到同一 404 语义;
// 业务端口不得新增任何 Actuator Controller 或端点映射。
.requestMatchers("/actuator/**").permitAll()
// Swagger UI // Swagger UI
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**").permitAll()
// 健康检查 API // 健康检查 API

49
mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java

@ -1,14 +1,11 @@
package com.mydreamjob.privacy; package com.mydreamjob.privacy;
import com.mydreamjob.application.Application; import com.mydreamjob.application.CompanyResumeAccessPolicy;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.privacy.dto.PrivacySettingsResponse; import com.mydreamjob.privacy.dto.PrivacySettingsResponse;
import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest; import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -18,8 +15,6 @@ import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.Set;
/** /**
* 学生隐私设置服务。 * 学生隐私设置服务。
* *
@ -31,14 +26,15 @@ import java.util.Set;
@RequiredArgsConstructor @RequiredArgsConstructor
public class PrivacySettingsService { public class PrivacySettingsService {
/** 视为"非有效投递"的状态:撤销或已拒绝的投递不允许企业查看简历 */
private static final Set<String> INEFFECTIVE_STATUSES = Set.of("WITHDRAWN", "REJECTED");
private final StudentPrivacySettingsRepository privacyRepository; private final StudentPrivacySettingsRepository privacyRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final ApplicationRepository applicationRepository;
private final CompanyRepository companyRepository;
private final AuditService auditService; private final AuditService auditService;
/**
* S03-B:企业简历可见性统一由失败关闭的授权策略判定。
* 策略强制校验「投递所属岗位属于该企业」,修复了此前只校验学生与状态、
* 不校验企业归属的缺陷。
*/
private final CompanyResumeAccessPolicy companyResumeAccessPolicy;
/** 校验当前用户必须为 STUDENT,否则抛 FORBIDDEN。 */ /** 校验当前用户必须为 STUDENT,否则抛 FORBIDDEN。 */
public void assertStudentRole(Long userId) { public void assertStudentRole(Long userId) {
@ -105,33 +101,20 @@ public class PrivacySettingsService {
} }
/** /**
* 企业是否可查看某学生的简历。 * 企业是否可查看某学生的简历(S03-B:委托给失败关闭的授权策略)。
*
* <p>判定需要同时满足:投递存在、投递学生与传入 {@code studentUserId} 一致、
* <strong>投递所属岗位属于 {@code companyId}</strong>,再按隐私设置决定:</p>
* *
* <ul> * <ul>
* <li>PRIVATE:企业不可见</li> * <li>无记录 / null / PRIVATE:不可见</li>
* <li>APPLIED_COMPANIES:仅当该学生在该企业存在有效投递时可见(WITHDRAWN/REJECTED 不算)</li> * <li>APPLIED_COMPANIES:岗位属于该企业且投递状态为
* <li>VERIFIED_COMPANIES:仅当企业 verified=true 时可见</li> * SUBMITTED/VIEWED/INTERVIEW/HIRED</li>
* <li>VERIFIED_COMPANIES:该企业 {@code verified=true} 且拥有该投递岗位</li>
* </ul> * </ul>
*/ */
public boolean canCompanyViewResume(Long studentUserId, Long companyId, Long applicationId) { public boolean canCompanyViewResume(Long studentUserId, Long companyId, Long applicationId) {
StudentPrivacySettings settings = privacyRepository.findByStudentUserId(studentUserId).orElse(null); return companyResumeAccessPolicy.isVisible(studentUserId, companyId, applicationId);
ResumeVisibility visibility = (settings == null || settings.getResumeVisibility() == null)
? ResumeVisibility.PRIVATE : settings.getResumeVisibility();
switch (visibility) {
case VERIFIED_COMPANIES:
Company company = companyRepository.findById(companyId).orElse(null);
return company != null && Boolean.TRUE.equals(company.getVerified());
case APPLIED_COMPANIES:
// 投递表不含 companyId,以 applicationId 定位具体投递校验有效状态(WITHDRAWN/REJECTED 不算有效)
Application app = applicationRepository.findById(applicationId).orElse(null);
if (app == null || !studentUserId.equals(app.getStudentUserId())) {
return false;
}
return !INEFFECTIVE_STATUSES.contains(app.getStatus());
case PRIVATE:
default:
return false;
}
} }
private void validate(PrivacySettingsUpdateRequest req) { private void validate(PrivacySettingsUpdateRequest req) {

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

@ -91,6 +91,14 @@ app:
issuer: mydreamjob issuer: mydreamjob
access-audience: mydreamjob-api access-audience: mydreamjob-api
refresh-audience: mydreamjob-auth refresh-audience: mydreamjob-auth
# S03-A:一次性管理员初始化。默认关闭;手机号无默认值,必须由部署方显式提供。
# 初始化成功后部署方必须移除该开关,普通启动不得隐式创建管理员。
bootstrap:
admin:
enabled: ${ADMIN_BOOTSTRAP_ENABLED:false}
phone: ${ADMIN_BOOTSTRAP_PHONE:}
nickname: ${ADMIN_BOOTSTRAP_NICKNAME:平台管理员}
resume: resume:
storage-type: ${RESUME_STORAGE_TYPE:local} storage-type: ${RESUME_STORAGE_TYPE:local}
storage-dir: ${RESUME_STORAGE_DIR:./resumes} storage-dir: ${RESUME_STORAGE_DIR:./resumes}
@ -154,14 +162,27 @@ recommendation:
enabled: ${RECOMMENDATION_ASSESSMENT_ENABLED:false} enabled: ${RECOMMENDATION_ASSESSMENT_ENABLED:false}
management: management:
# S03-C:Actuator 隔离到独立管理端口,默认只绑定回环地址。
# 业务端口不再提供 /actuator/metrics 与 /actuator/prometheus;
# 需要采集指标时由部署层通过受控网络或反向代理访问管理端口。
server:
port: ${MANAGEMENT_SERVER_PORT:8081}
address: ${MANAGEMENT_SERVER_ADDRESS:127.0.0.1}
endpoints: endpoints:
web: web:
exposure: exposure:
include: health,info,metrics,prometheus include: health,info,metrics,prometheus
# S03-C:显式开启 Prometheus 指标导出。仅暴露端点 ID 而不开启导出时,
# /actuator/prometheus 不会注册(暴露清单会指向一个不存在的端点)。
prometheus:
metrics:
export:
enabled: true
endpoint: endpoint:
health: health:
show-details: when-authorized # 健康探针只返回最小 UP/DOWN,不暴露数据库、Redis、RabbitMQ、磁盘或连接池细节。
show-components: always show-details: never
show-components: never
health: health:
db: db:
enabled: true enabled: true

40
mydreamjob-server/src/main/resources/db/migration/V43__disable_legacy_seed_accounts.sql

@ -0,0 +1,40 @@
-- S03-A:禁用仍保持 V1_1 原始指纹的 Flyway 演示账号
--
-- 背景:V1_1__seed_data.sql 无环境隔离地插入了 ADMIN / STUDENT / COMPANY 三个演示账号,
-- 并带有公开的 bcrypt 口令哈希。这些账号不能在生产继续可用。
--
-- 原则:
-- * 只将 users.is_active 由 1 置为 0;不删除用户、角色、审计或任何业务数据,不重置自增;
-- * 不依赖固定 id(不使用 id = 1/2/3);
-- * 每条 UPDATE 同时匹配 V1_1 中的**原始手机号 + 原始邮箱 + 原始公开 bcrypt 哈希 +
-- 原始主角色 + is_active = 1**;任一指纹被运营人员改过的账号都不会命中,因而保持不受影响;
-- * 幂等:命中行被置为 is_active = 0 后,再次执行不会再匹配(条件含 is_active = 1)。
--
-- 认证签发链已拒绝 inactive 用户,因此被禁用的账号无法再取得会话。
-- ADMIN
UPDATE users
SET is_active = 0
WHERE phone = '13800000001'
AND email = 'admin@mydreamjob.com'
AND password_hash = '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi'
AND role = 'ADMIN'
AND is_active = 1;
-- STUDENT
UPDATE users
SET is_active = 0
WHERE phone = '13800000002'
AND email = 'student@mydreamjob.com'
AND password_hash = '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi'
AND role = 'STUDENT'
AND is_active = 1;
-- COMPANY
UPDATE users
SET is_active = 0
WHERE phone = '13800000003'
AND email = 'company@mydreamjob.com'
AND password_hash = '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi'
AND role = 'COMPANY'
AND is_active = 1;

320
mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeAccessPolicyTest.java

@ -0,0 +1,320 @@
package com.mydreamjob.application;
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.job.Job;
import com.mydreamjob.job.JobRepository;
import com.mydreamjob.privacy.ResumeVisibility;
import com.mydreamjob.privacy.StudentPrivacySettings;
import com.mydreamjob.privacy.StudentPrivacySettingsRepository;
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.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.when;
/**
* S03-B 企业简历授权决策矩阵(设计 §4.3)。
*
* <p>真实策略 + Mock Repository;覆盖三种可见性 × 全部投递状态 × 公司归属 × 跨企业 × 未认证,
* 以及下载入口的拒绝语义与错误码。</p>
*/
@ExtendWith(MockitoExtension.class)
// 决策矩阵用例会一次性准备好「企业 + 岗位 + 投递 + 隐私」的完整上下文,
// 而拒绝分支会提前返回、不消费全部 stub —— 这是刻意覆盖矩阵的必然结果,
// 因此关闭「未使用 stub」的严格校验,断言本身仍逐条生效。
@MockitoSettings(strictness = Strictness.LENIENT)
class CompanyResumeAccessPolicyTest {
private static final Long STUDENT_ID = 100L;
private static final Long COMPANY_ID = 200L;
private static final Long OTHER_COMPANY_ID = 201L;
private static final Long COMPANY_USER_ID = 300L;
private static final Long APP_ID = 400L;
private static final Long JOB_ID = 500L;
private static final List<String> ALL_STATUSES = List.of(
"SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN");
private static final List<String> EFFECTIVE = List.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED");
private static final List<String> INEFFECTIVE = List.of("REJECTED", "WITHDRAWN");
@Mock private CompanyRepository companyRepository;
@Mock private ApplicationRepository applicationRepository;
@Mock private JobRepository jobRepository;
@Mock private StudentPrivacySettingsRepository privacyRepository;
@InjectMocks private CompanyResumeAccessPolicy policy;
// ── 工具 ────────────────────────────────────────────────────
private void givenApplication(String status) {
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(
Application.builder().id(APP_ID).jobId(JOB_ID).studentUserId(STUDENT_ID).status(status).build()));
}
private void givenJobOwnedBy(Long companyId) {
when(jobRepository.findById(JOB_ID)).thenReturn(Optional.of(
Job.builder().id(JOB_ID).companyId(companyId).build()));
}
private void givenVisibility(ResumeVisibility visibility) {
when(privacyRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(
StudentPrivacySettings.builder().studentUserId(STUDENT_ID)
.resumeVisibility(visibility).contactVisible(false).version(0L).build()));
}
private void givenCompany(Long companyId, boolean verified) {
when(companyRepository.findById(companyId)).thenReturn(Optional.of(
Company.builder().id(companyId).verified(verified).build()));
}
// ── 参数与记录缺失:一律拒绝 ────────────────────────────────
@Test
void nullArgumentsAreRejected() {
assertFalse(policy.isVisible(null, COMPANY_ID, APP_ID));
assertFalse(policy.isVisible(STUDENT_ID, null, APP_ID));
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, null));
}
@Test
void missingApplicationIsRejected() {
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.empty());
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID));
}
@Test
void studentMismatchIsRejected() {
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(
Application.builder().id(APP_ID).jobId(JOB_ID).studentUserId(999L).status("SUBMITTED").build()));
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID));
}
@Test
void missingJobIsRejected() {
givenApplication("SUBMITTED");
when(jobRepository.findById(JOB_ID)).thenReturn(Optional.empty());
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID));
}
// ── PRIVATE / 无记录 ───────────────────────────────────────
@Test
void privateIsRejectedForEveryStatusEvenWhenCompanyVerified() {
for (String status : ALL_STATUSES) {
givenApplication(status);
givenJobOwnedBy(COMPANY_ID);
givenVisibility(ResumeVisibility.PRIVATE);
givenCompany(COMPANY_ID, true);
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), "PRIVATE 必须拒绝:" + status);
}
}
@Test
void noSettingsRecordIsTreatedAsPrivate() {
for (String status : ALL_STATUSES) {
givenApplication(status);
givenJobOwnedBy(COMPANY_ID);
when(privacyRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty());
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), "无记录必须按 PRIVATE 拒绝:" + status);
}
}
@Test
void nullVisibilityIsTreatedAsPrivate() {
givenApplication("SUBMITTED");
givenJobOwnedBy(COMPANY_ID);
when(privacyRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(
StudentPrivacySettings.builder().studentUserId(STUDENT_ID)
.resumeVisibility(null).contactVisible(false).version(0L).build()));
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID));
}
// ── APPLIED_COMPANIES ──────────────────────────────────────
@Test
void appliedCompaniesAllowsOnlyEffectiveStatusesWhenCompanyOwnsJob() {
givenJobOwnedBy(COMPANY_ID);
givenVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String status : ALL_STATUSES) {
givenApplication(status);
boolean expected = EFFECTIVE.contains(status);
assertEquals(expected, policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID),
"APPLIED_COMPANIES 状态 " + status + " 判定不符");
}
}
// ── APPLIED_COMPANIES 必须是白名单(失败关闭)──────────────
@Test
void appliedCompaniesAllowsExactlyFourReviewedStatuses() {
givenJobOwnedBy(COMPANY_ID);
givenVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String status : ALL_STATUSES) {
givenApplication(status);
boolean expected = EFFECTIVE.contains(status);
assertEquals(expected, policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID),
"只有四个已审阅状态可放行:" + status);
}
}
@Test
void appliedCompaniesRejectsNullStatus() {
givenJobOwnedBy(COMPANY_ID);
givenVisibility(ResumeVisibility.APPLIED_COMPANIES);
givenApplication(null);
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), "null 状态必须拒绝");
}
@Test
void appliedCompaniesRejectsBlankStatus() {
givenJobOwnedBy(COMPANY_ID);
givenVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String blank : new String[]{"", " ", " ", "\t"}) {
givenApplication(blank);
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), "空白状态必须拒绝:'" + blank + "'");
}
}
@Test
void appliedCompaniesRejectsUnknownStatus() {
givenJobOwnedBy(COMPANY_ID);
givenVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String unknown : new String[]{"PENDING", "DRAFT", "ARCHIVED", "DELETED", "REVIEWING"}) {
givenApplication(unknown);
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), "未知状态必须拒绝:" + unknown);
}
}
@Test
void appliedCompaniesRejectsLowercaseStatus() {
givenJobOwnedBy(COMPANY_ID);
givenVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String lower : new String[]{"submitted", "viewed", "interview", "hired"}) {
givenApplication(lower);
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID),
"大小写变体必须拒绝(不得宽松匹配):" + lower);
}
}
@Test
void appliedCompaniesRejectsOtherCompanyEvenWithEffectiveApplication() {
givenJobOwnedBy(OTHER_COMPANY_ID);
givenVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String status : EFFECTIVE) {
givenApplication(status);
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID),
"跨企业必须拒绝(投递属于其他公司的岗位):" + status);
}
}
// ── VERIFIED_COMPANIES ─────────────────────────────────────
@Test
void verifiedCompaniesAllowsAnyStatusWhenVerifiedAndOwnsJob() {
givenJobOwnedBy(COMPANY_ID);
givenVisibility(ResumeVisibility.VERIFIED_COMPANIES);
givenCompany(COMPANY_ID, true);
for (String status : ALL_STATUSES) {
givenApplication(status);
assertTrue(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID),
"VERIFIED_COMPANIES 已认证且拥有岗位时状态不额外限制:" + status);
}
}
@Test
void verifiedCompaniesRejectsUnverifiedCompany() {
givenJobOwnedBy(COMPANY_ID);
givenVisibility(ResumeVisibility.VERIFIED_COMPANIES);
givenCompany(COMPANY_ID, false);
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID));
}
@Test
void verifiedCompaniesRejectsUnknownCompany() {
givenJobOwnedBy(COMPANY_ID);
givenVisibility(ResumeVisibility.VERIFIED_COMPANIES);
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.empty());
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID));
}
@Test
void verifiedCompaniesRejectsWhenCompanyDoesNotOwnJob() {
givenJobOwnedBy(OTHER_COMPANY_ID);
givenVisibility(ResumeVisibility.VERIFIED_COMPANIES);
givenCompany(COMPANY_ID, true);
for (String status : ALL_STATUSES) {
givenApplication(status);
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID),
"已验证企业也必须拥有该投递岗位:" + status);
}
}
// ── 下载入口的拒绝语义 ────────────────────────────────────
@Test
void authorizeForDownloadRejectsUserWithoutCompany() {
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.empty());
BusinessException ex = assertThrows(BusinessException.class,
() -> policy.authorizeForDownload(COMPANY_USER_ID, APP_ID));
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt());
}
@Test
void authorizeForDownloadReturnsNotFoundForMissingApplication() {
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.of(
Company.builder().id(COMPANY_ID).verified(true).build()));
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.empty());
BusinessException ex = assertThrows(BusinessException.class,
() -> policy.authorizeForDownload(COMPANY_USER_ID, APP_ID));
assertEquals(ErrorCode.APPLICATION_NOT_FOUND.getCode(), ex.getErrorCodeInt());
}
@Test
void authorizeForDownloadRejectsCrossCompanyWithForbidden() {
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.of(
Company.builder().id(COMPANY_ID).verified(true).build()));
givenApplication("SUBMITTED");
givenJobOwnedBy(OTHER_COMPANY_ID);
givenVisibility(ResumeVisibility.APPLIED_COMPANIES);
BusinessException ex = assertThrows(BusinessException.class,
() -> policy.authorizeForDownload(COMPANY_USER_ID, APP_ID));
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt());
}
@Test
void authorizeForDownloadReturnsStudentIdWhenAllowed() {
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.of(
Company.builder().id(COMPANY_ID).verified(false).build()));
givenApplication("SUBMITTED");
givenJobOwnedBy(COMPANY_ID);
givenVisibility(ResumeVisibility.APPLIED_COMPANIES);
assertEquals(STUDENT_ID, policy.authorizeForDownload(COMPANY_USER_ID, APP_ID).studentUserId());
}
@Test
void authorizeForDownloadRejectsNullArguments() {
assertEquals(ErrorCode.FORBIDDEN.getCode(),
assertThrows(BusinessException.class,
() -> policy.authorizeForDownload(null, APP_ID)).getErrorCodeInt());
assertEquals(ErrorCode.FORBIDDEN.getCode(),
assertThrows(BusinessException.class,
() -> policy.authorizeForDownload(COMPANY_USER_ID, null)).getErrorCodeInt());
}
}

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

@ -0,0 +1,606 @@
package com.mydreamjob.application;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.auth.support.TestAuthTokens;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
import com.mydreamjob.job.JobStatus;
import com.mydreamjob.privacy.ResumeVisibility;
import com.mydreamjob.privacy.StudentPrivacySettings;
import com.mydreamjob.privacy.StudentPrivacySettingsRepository;
import com.mydreamjob.profile.StudentProfile;
import com.mydreamjob.profile.StudentProfileRepository;
import com.mydreamjob.profile.storage.ResumeStorageService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* S03-B 企业简历下载权限矩阵(设计 §4.3),真实 SecurityFilterChain + 真实 H2 + 真实本地存储。
*
* <p>只用一个计数包装器包住存储实现,用于证明**拒绝路径零文件读取**;
* 授权链本身完全真实,未被 Mock。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
@TestPropertySource(properties = "app.resume.storage-dir=target/s03-test-resumes")
class CompanyResumeDownloadIntegrationTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(8800);
private static final List<String> ALL_STATUSES =
List.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN");
private static final List<String> EFFECTIVE = List.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED");
/** 统计 read() 调用次数,用于断言拒绝路径不触碰文件。 */
static final AtomicInteger READ_CALLS = new AtomicInteger();
@TestConfiguration
static class CountingStorageConfig {
@Bean
@Primary
ResumeStorageService countingResumeStorageService(
com.mydreamjob.profile.storage.LocalResumeStorageService delegate) {
return new ResumeStorageService() {
@Override
public String store(Long userId, org.springframework.web.multipart.MultipartFile file) {
return delegate.store(userId, file);
}
@Override
public byte[] read(String storagePath) {
READ_CALLS.incrementAndGet();
return delegate.read(storagePath);
}
@Override
public void delete(String storagePath) {
delegate.delete(storagePath);
}
};
}
}
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Autowired private MockMvc mockMvc;
@Autowired private UserRepository userRepository;
@Autowired private CompanyRepository companyRepository;
@Autowired private JobRepository jobRepository;
@Autowired private ApplicationRepository applicationRepository;
@Autowired private StudentProfileRepository profileRepository;
@Autowired private StudentPrivacySettingsRepository privacyRepository;
@Autowired private ResumeStorageService resumeStorageService;
@Autowired private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
private Long companyUserId;
private Long otherCompanyUserId;
private Long companyId;
private Long otherCompanyId;
private Long studentUserId;
private Long adminUserId;
private Long jobId;
private Long applicationId;
private String storagePath;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
READ_CALLS.set(0);
ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(anyString())).thenReturn(false);
companyUserId = createUser(User.Role.COMPANY, "S03 企业");
otherCompanyUserId = createUser(User.Role.COMPANY, "S03 他企");
studentUserId = createUser(User.Role.STUDENT, "S03 学生");
adminUserId = createUser(User.Role.ADMIN, "S03 管理员");
companyId = companyRepository.save(Company.builder()
.ownerUserId(companyUserId).name("S03 甲企业").verified(false).build()).getId();
otherCompanyId = companyRepository.save(Company.builder()
.ownerUserId(otherCompanyUserId).name("S03 乙企业").verified(true).build()).getId();
jobId = jobRepository.save(Job.builder()
.companyId(companyId).title("S03 岗位").description("d").city("杭州")
.employmentType("FULL_TIME").status(JobStatus.PUBLISHED.name()).build()).getId();
applicationId = applicationRepository.save(Application.builder()
.jobId(jobId).studentUserId(studentUserId).status("SUBMITTED").build()).getId();
// 真实写入一个 PDF 文件并挂到学生档案
MockMultipartFile file = new MockMultipartFile("file", "resume.pdf", "application/pdf",
"%PDF-1.4 s03 content".getBytes(StandardCharsets.UTF_8));
storagePath = resumeStorageService.store(studentUserId, file);
profileRepository.save(StudentProfile.builder()
.userId(studentUserId)
.resumeUrl(storagePath)
.resumeOriginalName("我的简历.pdf")
.resumeContentType("application/pdf")
.resumeSize(file.getSize())
.build());
}
@AfterEach
void tearDown() {
if (storagePath != null) {
resumeStorageService.delete(storagePath);
}
profileRepository.findByUserId(studentUserId).ifPresent(profileRepository::delete);
privacyRepository.findByStudentUserId(studentUserId).ifPresent(privacyRepository::delete);
if (applicationId != null) applicationRepository.deleteById(applicationId);
if (jobId != null) jobRepository.deleteById(jobId);
if (companyId != null) companyRepository.deleteById(companyId);
if (otherCompanyId != null) companyRepository.deleteById(otherCompanyId);
List.of(companyUserId, otherCompanyUserId, studentUserId, adminUserId)
.forEach(id -> { if (id != null) userRepository.deleteById(id); });
}
private Long createUser(User.Role role, String nickname) {
return userRepository.save(User.builder()
.phone("17" + String.format("%09d", PHONE_SEQ.incrementAndGet()))
.nickname(nickname)
.role(role)
.roles(Set.of(role))
.active(true)
.verified(true)
.build()).getId();
}
private void setVisibility(ResumeVisibility visibility) {
privacyRepository.findByStudentUserId(studentUserId)
.ifPresent(privacyRepository::delete);
if (visibility != null) {
privacyRepository.saveAndFlush(StudentPrivacySettings.builder()
.studentUserId(studentUserId).resumeVisibility(visibility)
.contactVisible(false).version(0L).build());
}
}
private void setStatus(String status) {
Application app = applicationRepository.findById(applicationId).orElseThrow();
app.setStatus(status);
applicationRepository.saveAndFlush(app);
}
private String url() {
return "/company/candidates/" + applicationId + "/resume";
}
private String companyToken() {
return TestAuthTokens.bearer(companyUserId, "COMPANY");
}
// ── 决策矩阵:3 种可见性 × 6 种状态 ─────────────────────────
@Test
void privateIsDeniedForEveryStatusAndNeverReadsFile() {
setVisibility(ResumeVisibility.PRIVATE);
for (String status : ALL_STATUSES) {
setStatus(status);
READ_CALLS.set(0);
int code = perform(companyToken());
assertThat(code).as("PRIVATE 必须 403:" + status).isEqualTo(403);
assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件:" + status).isZero();
}
}
@Test
void missingSettingsIsDeniedAsPrivate() {
setVisibility(null);
for (String status : ALL_STATUSES) {
setStatus(status);
READ_CALLS.set(0);
assertThat(perform(companyToken())).as("无设置按 PRIVATE 拒绝:" + status).isEqualTo(403);
assertThat(READ_CALLS.get()).isZero();
}
}
@Test
void appliedCompaniesAllowsOnlyEffectiveStatusesForOwningCompany() throws Exception {
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String status : ALL_STATUSES) {
setStatus(status);
READ_CALLS.set(0);
if (EFFECTIVE.contains(status)) {
mockMvc.perform(get(url()).header("Authorization", companyToken()))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(content().contentType("application/pdf"));
assertThat(READ_CALLS.get()).as("授权通过应读取一次:" + status).isEqualTo(1);
} else {
assertThat(perform(companyToken())).as("WITHDRAWN/REJECTED 应 403:" + status).isEqualTo(403);
assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件:" + status).isZero();
}
}
}
@Test
void verifiedCompaniesAllowsAnyStatusOnlyForVerifiedOwningCompany() {
// 甲企业未认证 → 全部拒绝
setVisibility(ResumeVisibility.VERIFIED_COMPANIES);
for (String status : ALL_STATUSES) {
setStatus(status);
READ_CALLS.set(0);
assertThat(perform(companyToken())).as("未认证企业应 403:" + status).isEqualTo(403);
assertThat(READ_CALLS.get()).isZero();
}
// 认证后 → 全部状态允许
Company company = companyRepository.findById(companyId).orElseThrow();
company.setVerified(true);
companyRepository.saveAndFlush(company);
for (String status : ALL_STATUSES) {
setStatus(status);
READ_CALLS.set(0);
assertThat(perform(companyToken())).as("已认证且拥有岗位应 200:" + status).isEqualTo(200);
assertThat(READ_CALLS.get()).isEqualTo(1);
}
}
// ── APPLIED_COMPANIES 白名单:退化状态必须 403 且零读取 ──────
@Test
void appliedCompaniesUnknownStatusReturns403WithoutReadingStorage() throws Exception {
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String unknown : new String[]{"PENDING", "DRAFT", "ARCHIVED", "submitted"}) {
setStatus(unknown);
READ_CALLS.set(0);
var result = mockMvc.perform(get(url()).header("Authorization", companyToken()))
.andReturn().getResponse();
assertThat(result.getStatus()).as("未知/变体状态必须 403:" + unknown).isEqualTo(403);
assertThat(result.getContentAsString()).as("业务码必须 2002:" + unknown).contains("\"code\":2002");
assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件:" + unknown).isZero();
}
}
@Test
void appliedCompaniesBlankStatusReturns403WithoutReadingStorage() throws Exception {
// 说明:applications.status 在 V2 中是 NOT NULL,因此 DB 层无法保存 NULL;
// NULL 情形由 CompanyResumeAccessPolicyTest 在内存中覆盖,这里用可达的空白值验证。
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String blank : new String[]{"", " "}) {
setStatus(blank);
READ_CALLS.set(0);
var result = mockMvc.perform(get(url()).header("Authorization", companyToken()))
.andReturn().getResponse();
assertThat(result.getStatus()).as("空白状态必须 403").isEqualTo(403);
assertThat(result.getContentAsString()).as("业务码必须 2002").contains("\"code\":2002");
assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件").isZero();
}
}
// ── 跨企业 ─────────────────────────────────────────────────
@Test
void otherCompanyIsAlwaysDenied() {
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
READ_CALLS.set(0);
assertThat(perform(TestAuthTokens.bearer(otherCompanyUserId, "COMPANY")))
.as("跨企业必须 403").isEqualTo(403);
assertThat(READ_CALLS.get()).isZero();
setVisibility(ResumeVisibility.VERIFIED_COMPANIES);
READ_CALLS.set(0);
assertThat(perform(TestAuthTokens.bearer(otherCompanyUserId, "COMPANY")))
.as("其他企业即使已认证也必须 403").isEqualTo(403);
assertThat(READ_CALLS.get()).isZero();
}
// ── 角色与非认证 ───────────────────────────────────────────
@Test
void studentAdminAndAnonymousAreDenied() {
READ_CALLS.set(0);
setVisibility(ResumeVisibility.VERIFIED_COMPANIES);
Company company = companyRepository.findById(companyId).orElseThrow();
company.setVerified(true);
companyRepository.saveAndFlush(company);
assertThat(perform(TestAuthTokens.bearer(studentUserId, "STUDENT"))).as("STUDENT 应 403").isEqualTo(403);
assertThat(perform(TestAuthTokens.bearer(adminUserId, "ADMIN"))).as("ADMIN 应 403").isEqualTo(403);
assertThat(performNoAuth()).as("匿名应 401").isEqualTo(401);
assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件").isZero();
}
@Test
void nonexistentApplicationIsNotFound() {
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
READ_CALLS.set(0);
String token = companyToken();
int code;
try {
code = mockMvc.perform(get("/company/candidates/99999999/resume").header("Authorization", token))
.andReturn().getResponse().getStatus();
} catch (Exception e) {
code = -1;
}
assertThat(code).as("不存在的投递应 404").isEqualTo(404);
assertThat(READ_CALLS.get()).isZero();
}
@Test
void missingResumeIsProfileNotFound() {
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
StudentProfile profile = profileRepository.findByUserId(studentUserId).orElseThrow();
profile.setResumeUrl(null);
profileRepository.saveAndFlush(profile);
assertThat(perform(companyToken())).as("授权通过但无简历应 404").isEqualTo(404);
}
// ── 响应脱敏 ───────────────────────────────────────────────
@Test
void responseDoesNotLeakPathOrIdentity() throws Exception {
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
String body = mockMvc.perform(get(url()).header("Authorization", companyToken()))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(body).as("不得回显存储路径").doesNotContain(storagePath);
assertThat(body).doesNotContain("resumeUrl");
assertThat(body).doesNotContain(String.valueOf(studentUserId));
assertThat(body).doesNotContain("studentUserId");
}
@Test
void contentDispositionUsesSanitizedOriginalName() throws Exception {
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
mockMvc.perform(get(url()).header("Authorization", companyToken()))
.andExpect(status().isOk())
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.containsString("attachment")))
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("../"))))
.andExpect(header().string("Cache-Control", "no-store"));
}
// ── 企业候选人列表/详情仍不含 resumeUrl ─────────────────────
@Test
void companyCandidateEndpointsStillExcludeResumeUrl() throws Exception {
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
String token = companyToken();
String detail = mockMvc.perform(get("/company/candidates/" + applicationId)
.header("Authorization", token))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(detail).doesNotContain("resumeUrl");
String list = mockMvc.perform(get("/company/candidates").header("Authorization", token))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(list).doesNotContain("resumeUrl");
}
// ── 运行时 OpenAPI 契约(S03 只新增一个企业下载 operation)──
@Test
void openApiContractForCompanyResumeDownload() throws Exception {
String json = mockMvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
var root = new com.fasterxml.jackson.databind.ObjectMapper().readTree(json);
var paths = root.path("paths");
var schemas = root.path("components").path("schemas");
long pathCount = paths.size();
long opCount = 0;
var it = paths.fieldNames();
while (it.hasNext()) {
var ops = paths.path(it.next());
var f = ops.fieldNames();
while (f.hasNext()) {
if (java.util.Set.of("get", "post", "put", "delete", "patch")
.contains(f.next())) {
opCount++;
}
}
}
assertThat(pathCount).as("S03 基线应为 176 paths").isEqualTo(176);
assertThat(opCount).as("S03 基线应为 214 operations").isEqualTo(214);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse();
assertThat(download.has("requestBody")).as("下载接口不得有 requestBody").isFalse();
// 不得存在按 studentUserId / candidateKey 下载简历的入口
var names = new java.util.ArrayList<String>();
paths.fieldNames().forEachRemaining(names::add);
for (String p : names) {
String lower = p.toLowerCase();
if (lower.contains("resume") || lower.contains("download")) {
assertThat(lower).as("不得按 studentUserId 下载:" + p).doesNotContain("studentuserid");
assertThat(lower).as("不得按 candidateKey 下载:" + p).doesNotContain("candidatekey");
}
}
// 企业候选人 DTO 不得含 resumeUrl / studentUserId / phone / email / storagePath
for (String dto : new String[]{"CandidateDetailView", "CandidateItemView"}) {
var node = schemas.path(dto);
if (node.isMissingNode()) {
continue;
}
String text = node.toString();
for (String forbidden : new String[]{"resumeUrl", "studentUserId", "phone", "email", "storagePath"}) {
assertThat(text).as(dto + " 不得包含 " + forbidden).doesNotContain(forbidden);
}
}
// Actuator 不得进入业务 OpenAPI
for (String p : names) {
assertThat(p).as("Actuator 不得出现在业务契约").doesNotStartWith("/actuator");
}
// 开发辅助:-Ds03.openapi.snapshot=<目录> 时导出快照
String snapshotDir = System.getProperty("s03.openapi.snapshot");
if (snapshotDir != null && !snapshotDir.isBlank()) {
java.nio.file.Files.write(
java.nio.file.Paths.get(snapshotDir, "openapi-rc.json"),
json.getBytes(StandardCharsets.UTF_8));
}
}
// ── 副作用:企业下载只读 ───────────────────────────────────
/**
* 表名**固定白名单**(不做任意外部表名拼接)+ 每张表明确选择的稳定业务字段。
* 字段名以真实表结构为准。
*/
private static java.util.Map<String, String> digestColumns() {
java.util.Map<String, String> m = new java.util.LinkedHashMap<>();
m.put("users", "id, phone, email, role, is_active, is_verified, updated_at");
m.put("user_roles", "user_id, role");
m.put("student_profiles",
"id, user_id, resume_url, resume_original_name, resume_content_type, resume_size, updated_at");
m.put("resume_versions",
"id, user_id, version_no, status, file_storage_path, file_name, content_type, file_size, updated_at");
m.put("applications", "id, job_id, student_user_id, status, updated_at");
m.put("jobs", "id, company_id, status, updated_at");
m.put("companies", "id, owner_user_id, verified, name, updated_at");
m.put("auth_tokens", "id, user_id, session_id, revoked, consumed_at, revoked_at, version, created_at");
m.put("audit_logs", "id, user_id, action, entity_type, entity_id, details, created_at");
m.put("candidate_dimension_scores",
"id, student_user_id, dimension, period, score, status, weight, rules_version, calculated_at, version");
m.put("notifications", "id, user_id, type, title, is_read, read_at, created_at");
m.put("messages", "id, conversation_id, sender_user_id, content, status, recalled_at, version, created_at");
m.put("scoring_risk_events",
"id, rule_config_id, subject_user_id, source_action, severity, occurrence_count, status, detected_at, version");
m.put("scoring_risk_actions", "id, risk_event_id, operator_user_id, action, reason_code, created_at");
m.put("talent_invitations",
"id, company_id, job_id, student_user_id, status, expires_at, decided_at, version, created_at");
return m;
}
/**
* 行级快照:按选定字段排序后取全部行,做深比较。
*
* <p>不使用 COUNT / SUM(id) / CRC32(id) 这类弱摘要;查询异常直接抛出使测试失败,
* 不允许被吞掉后写入占位值。</p>
*/
private java.util.Map<String, java.util.List<java.util.Map<String, Object>>> snapshot() {
java.util.Map<String, java.util.List<java.util.Map<String, Object>>> out = new java.util.LinkedHashMap<>();
for (java.util.Map.Entry<String, String> e : digestColumns().entrySet()) {
String columns = e.getValue();
String sql = "SELECT " + columns + " FROM " + e.getKey() + " ORDER BY " + columns;
out.put(e.getKey(), jdbcTemplate.queryForList(sql));
}
return out;
}
@Test
void companyDownloadHasNoSideEffectsOnBusinessTables() {
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
// 先取 token:测试夹具在签发时会写入 auth_tokens/users,必须发生在基线快照之前,
// 否则夹具自身的记录会被误判为「下载接口的副作用」。
String token = companyToken();
var before = snapshot();
assertThat(perform(token)).as("授权通过应 200").isEqualTo(200);
var after = snapshot();
for (String table : before.keySet()) {
assertThat(after.get(table))
.as(table + " 不得因企业下载而变化(逐行内容比较)")
.isEqualTo(before.get(table));
}
}
/**
* 反证:摘要必须能发现单行变化,否则前面的「零变化」断言没有说服力。
*/
@Test
void snapshotDetectsSingleRowChange() {
var before = snapshot();
Application app = applicationRepository.findById(applicationId).orElseThrow();
String original = app.getStatus();
app.setStatus(original.equals("INTERVIEW") ? "HIRED" : "INTERVIEW");
applicationRepository.saveAndFlush(app);
try {
var after = snapshot();
assertThat(after.get("applications"))
.as("单行 status 变化必须被摘要发现").isNotEqualTo(before.get("applications"));
} finally {
Application restore = applicationRepository.findById(applicationId).orElseThrow();
restore.setStatus(original);
applicationRepository.saveAndFlush(restore);
}
}
@Test
void snapshotCoversEveryRequiredTableAndColumn() {
var snap = snapshot();
for (String table : new String[]{
"users", "user_roles", "student_profiles", "resume_versions", "applications",
"jobs", "companies", "auth_tokens", "audit_logs", "candidate_dimension_scores",
"notifications", "messages", "scoring_risk_events", "scoring_risk_actions",
"talent_invitations"}) {
assertThat(snap).as("必须覆盖 " + table).containsKey(table);
}
// 抽查关键字段确实被取到(而非空 Map)
assertThat(snap.get("applications").get(0)).containsKeys("status", "updated_at");
assertThat(snap.get("auth_tokens").get(0)).containsKeys("revoked", "version");
}
// ── 工具 ───────────────────────────────────────────────────
private int perform(String token) {
try {
return mockMvc.perform(get(url()).header("Authorization", token))
.andReturn().getResponse().getStatus();
} catch (Exception e) {
return -1;
}
}
private int performNoAuth() {
try {
return mockMvc.perform(get(url())).andReturn().getResponse().getStatus();
} catch (Exception e) {
return -1;
}
}
}

299
mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapConcurrencyLoggingTest.java

@ -0,0 +1,299 @@
package com.mydreamjob.auth;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.classic.spi.IThrowableProxy;
import ch.qos.logback.classic.spi.StackTraceElementProxy;
import ch.qos.logback.core.read.ListAppender;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
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 java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* S03 定向整改:管理员初始化并发冲突**不得**把完整手机号写进任何日志。
*
* <p>并发竞争由数据库唯一约束兜底时,Hibernate 的 {@code SqlExceptionHelper} 会在业务代码
* 捕获异常**之前**记录整条 SQL 与绑定参数 —— 手机号因此进入日志。本类捕获
* root / {@code com.mydreamjob} / {@code org.hibernate} / {@code org.springframework.jdbc}
* 四类 logger,并把 {@code throwableProxy} 的完整文本(含 cause 链与堆栈)纳入扫描。</p>
*/
@SpringBootTest(properties = {
"app.bootstrap.admin.enabled=true",
"app.bootstrap.admin.phone=13800000666",
"app.bootstrap.admin.nickname=并发引导管理员",
})
@ActiveProfiles("integration-test")
class AdminBootstrapConcurrencyLoggingTest {
private static final String BOOTSTRAP_PHONE = "13800000666";
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private AdminBootstrapRunner runner;
@Autowired
private JdbcTemplate jdbcTemplate;
private final List<ListAppender<ILoggingEvent>> appenders = new ArrayList<>();
private final List<Logger> attachedLoggers = new ArrayList<>();
private final List<Long> deactivatedAdmins = new ArrayList<>();
private static final String[] CAPTURED_LOGGERS = {
org.slf4j.Logger.ROOT_LOGGER_NAME,
"com.mydreamjob",
"org.hibernate",
"org.springframework.jdbc",
};
@BeforeEach
void setUp() {
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)",
BOOTSTRAP_PHONE);
jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE);
// 保证「无活动 ADMIN」前置条件与执行顺序无关
deactivatedAdmins.clear();
for (Long id : jdbcTemplate.queryForList(
"SELECT id FROM users WHERE role = 'ADMIN' AND is_active = 1", Long.class)) {
jdbcTemplate.update("UPDATE users SET is_active = 0 WHERE id = ?", id);
deactivatedAdmins.add(id);
}
for (String name : CAPTURED_LOGGERS) {
Logger logger = (Logger) LoggerFactory.getLogger(name);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
// 刻意**不**下调级别:按应用自身配置(root=WARN、com.mydreamjob=INFO)捕获,
// 扫描结果才等于「生产到底写出了什么」。
// 框架在 TRACE 下的绑定参数日志不属于生产可见输出,不应计入本断言。
appenders.add(appender);
attachedLoggers.add(logger);
}
}
@AfterEach
void tearDown() {
for (int i = 0; i < attachedLoggers.size(); i++) {
attachedLoggers.get(i).detachAppender(appenders.get(i));
appenders.get(i).stop();
}
attachedLoggers.clear();
appenders.clear();
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)",
BOOTSTRAP_PHONE);
jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE);
for (Long id : deactivatedAdmins) {
jdbcTemplate.update("UPDATE users SET is_active = 1 WHERE id = ?", id);
}
deactivatedAdmins.clear();
}
// ── 日志采集(含 throwableProxy 全文)────────────────────────
/** 把 throwableProxy 的类名、message、堆栈与 cause 链完整渲染为文本。 */
private static String renderThrowable(IThrowableProxy proxy) {
if (proxy == null) {
return "";
}
StringBuilder sb = new StringBuilder();
sb.append(proxy.getClassName()).append(": ").append(proxy.getMessage()).append('\n');
StackTraceElementProxy[] stack = proxy.getStackTraceElementProxyArray();
if (stack != null) {
for (StackTraceElementProxy s : stack) {
sb.append(" at ").append(s.getStackTraceElement()).append('\n');
}
}
if (proxy.getCause() != null) {
sb.append("Caused by: ").append(renderThrowable(proxy.getCause()));
}
return sb.toString();
}
/**
* 所有被捕获 logger 的完整文本:formattedMessage + 参数 + throwable 全文。
*
* <p>排除 {@code org.springframework.test.*}:测试框架自身会在 DEBUG 记录
* 上下文缓存键(其中包含本次 {@code @SpringBootTest} 的属性列表,因而含手机号)。
* 那是测试基础设施的输出,不是产品日志 —— 生产运行不存在该 logger。</p>
*/
private String capturedText() {
StringBuilder sb = new StringBuilder();
for (ListAppender<ILoggingEvent> appender : appenders) {
for (ILoggingEvent e : appender.list) {
if (e.getLoggerName() != null && e.getLoggerName().startsWith("org.springframework.test")) {
continue;
}
sb.append(e.getFormattedMessage()).append('\n');
Object[] args = e.getArgumentArray();
if (args != null) {
for (Object a : args) {
sb.append(String.valueOf(a)).append('\n');
}
}
sb.append(renderThrowable(e.getThrowableProxy()));
}
}
return sb.toString();
}
/** 扫描器有效性探针:注入一个标记,必须能被同一个扫描函数命中。 */
@Test
void scannerDetectsInjectedProbe() {
Logger logger = (Logger) LoggerFactory.getLogger("com.mydreamjob");
String probe = "S03_SCANNER_PROBE_" + BOOTSTRAP_PHONE;
logger.error("probe {}", probe);
assertThat(capturedText()).as("扫描器必须能命中注入的探针,证明不是空转")
.contains(probe);
}
// ── 并发冲突不得泄漏手机号 ──────────────────────────────────
private List<String> runConcurrently() throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch ready = new CountDownLatch(2);
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 2; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
start.await(20, TimeUnit.SECONDS);
try {
runner.run(null);
return "SUCCESS";
} catch (Throwable t) {
return t.getClass().getName();
}
}));
}
assertThat(ready.await(20, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<String> results = new ArrayList<>();
for (Future<String> f : futures) {
results.add(f.get(60, TimeUnit.SECONDS));
}
pool.shutdownNow();
return results;
}
@Test
void adminBootstrapConcurrentConflictNeverLogsConfiguredPhone() throws Exception {
runConcurrently();
String log = capturedText();
assertThat(log).as("完整管理员手机号不得出现在任何 logger(含 throwableProxy)")
.doesNotContain(BOOTSTRAP_PHONE);
assertThat(log).as("不得出现 SQL 绑定参数回显").doesNotContain("binding parameter");
assertThat(log).as("不得出现 User.toString()").doesNotContain("User(id=");
assertThat(log).as("不得出现 password/token/secret 值")
.doesNotContainIgnoringCase("password=")
.doesNotContainIgnoringCase("secret=");
}
@Test
void concurrentBootstrapNeverLogsPhoneAcrossAllLoggers() throws Exception {
List<String> results = runConcurrently();
String log = capturedText();
assertThat(log).doesNotContain(BOOTSTRAP_PHONE);
// 冲突方不得把原始 Hibernate/JDBC 异常带出来
assertThat(results).as("并发结果不得包含原始约束异常类型")
.noneMatch(r -> r.contains("DataIntegrityViolationException")
|| r.contains("ConstraintViolationException")
|| r.contains("SQLException"));
}
@Test
void concurrentBootstrapProducesOneValidAdmin() throws Exception {
runConcurrently();
assertThat(countByPhone()).as("并发后同手机号用户恰好一行").isEqualTo(1);
assertThat(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id = ur.user_id "
+ "WHERE u.phone = ? AND ur.role = 'ADMIN'", Integer.class, BOOTSTRAP_PHONE))
.as("user_roles 中 ADMIN 恰好一行").isEqualTo(1);
assertThat(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id = ur.user_id "
+ "WHERE u.phone = ? AND ur.role IN ('STUDENT','COMPANY')", Integer.class, BOOTSTRAP_PHONE))
.as("不得出现 STUDENT/COMPANY 附加角色").isZero();
List<java.util.Map<String, Object>> row = jdbcTemplate.queryForList(
"SELECT is_active, is_verified, password_hash FROM users WHERE phone = ?", BOOTSTRAP_PHONE);
assertThat(row).hasSize(1);
assertThat(((Number) row.get(0).get("is_active")).intValue()).isEqualTo(1);
assertThat(((Number) row.get(0).get("is_verified")).intValue()).isEqualTo(1);
assertThat(row.get(0).get("password_hash")).as("不得设置默认密码").isNull();
}
@Test
void repeatedBootstrapRemainsIdempotent() {
runner.run(null);
runner.run(null);
runner.run(null);
assertThat(countByPhone()).isEqualTo(1);
assertThat(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users WHERE role='ADMIN' AND is_active=1", Integer.class))
.isEqualTo(1);
}
@Test
void existingStudentWithSamePhoneStillFailsWithoutPrivilegeEscalation() {
jdbcTemplate.update(
"INSERT INTO users (phone, nickname, password_hash, role, is_active, is_verified) "
+ "VALUES (?, '普通学生', NULL, 'STUDENT', 1, 1)", BOOTSTRAP_PHONE);
try {
assertThat(org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class,
() -> runner.run(null)).getMessage())
.as("冲突文案必须固定且不含手机号")
.doesNotContain(BOOTSTRAP_PHONE);
} finally {
jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE);
}
assertThat(capturedText()).as("冲突路径也不得把手机号写进日志")
.doesNotContain(BOOTSTRAP_PHONE);
}
@Test
void conflictExceptionHasNoSensitiveCause() {
jdbcTemplate.update(
"INSERT INTO users (phone, nickname, password_hash, role, is_active, is_verified) "
+ "VALUES (?, '占位用户', NULL, 'STUDENT', 1, 1)", BOOTSTRAP_PHONE);
try {
RuntimeException ex = org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class,
() -> runner.run(null));
assertThat(ex.getMessage()).doesNotContain(BOOTSTRAP_PHONE);
assertThat(ex.getCause()).as("不得携带原始数据库异常作为 cause").isNull();
assertThat(ex.getClass().getName()).doesNotContain("DataIntegrityViolation");
} finally {
jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE);
}
}
private int countByPhone() {
Integer n = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users WHERE phone = ?", Integer.class, BOOTSTRAP_PHONE);
return n == null ? 0 : n;
}
}

41
mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapDisabledByDefaultTest.java

@ -0,0 +1,41 @@
package com.mydreamjob.auth;
import com.mydreamjob.config.AdminBootstrapProperties;
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.context.ApplicationContext;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
/**
* S03-A:不显式开启时,初始化器**不得**被装配,普通启动不会隐式创建管理员。
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class AdminBootstrapDisabledByDefaultTest {
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ApplicationContext context;
@Autowired
private AdminBootstrapProperties properties;
@Test
void runnerIsNotAssembledByDefault() {
assertThat(context.getBeanNamesForType(AdminBootstrapRunner.class))
.as("默认(开关未开启)不得装配管理员初始化器").isEmpty();
}
@Test
void defaultPropertiesAreDisabledAndHaveNoPhone() {
assertThat(properties.getEnabled()).isEqualTo("false");
assertThat(properties.getPhone()).isEmpty();
}
}

235
mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapIntegrationTest.java

@ -0,0 +1,235 @@
package com.mydreamjob.auth;
import com.mydreamjob.config.AdminBootstrapProperties;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.context.ApplicationContext;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import java.util.List;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* S03-A 管理员初始化的真实上下文验证:条件装配、真实 H2 建号、
* {@code users.role} 与 {@code user_roles} 一致、幂等、并发冲突。
*/
@SpringBootTest(properties = {
"app.bootstrap.admin.enabled=true",
"app.bootstrap.admin.phone=13800000777",
"app.bootstrap.admin.nickname=引导管理员",
})
@ActiveProfiles("integration-test")
class AdminBootstrapIntegrationTest {
private static final String BOOTSTRAP_PHONE = "13800000777";
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ApplicationContext context;
@Autowired
private AdminBootstrapRunner runner;
@Autowired
private AdminBootstrapProperties properties;
@Autowired
private UserRepository userRepository;
@Autowired
private JdbcTemplate jdbcTemplate;
private ch.qos.logback.core.read.ListAppender<ch.qos.logback.classic.spi.ILoggingEvent> appender;
private ch.qos.logback.classic.Logger appLogger;
/** 用例开始前被临时停用、结束后需恢复的其他 ADMIN id。 */
private final java.util.List<Long> deactivatedAdmins = new java.util.ArrayList<>();
@BeforeEach
void clearBootstrapArtifacts() {
// 清掉本测试可能留下的引导账号,使「无活动 ADMIN」前置条件成立
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)",
BOOTSTRAP_PHONE);
jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE);
// 其他测试类可能留下活动 ADMIN,破坏「无活动 ADMIN」前置条件。这里临时停用它们,
// 用例结束恢复,使本类的断言不依赖测试执行顺序。
deactivatedAdmins.clear();
for (Long id : jdbcTemplate.queryForList(
"SELECT id FROM users WHERE role = 'ADMIN' AND is_active = 1", Long.class)) {
jdbcTemplate.update("UPDATE users SET is_active = 0 WHERE id = ?", id);
deactivatedAdmins.add(id);
}
appLogger = (ch.qos.logback.classic.Logger)
org.slf4j.LoggerFactory.getLogger("com.mydreamjob");
appender = new ch.qos.logback.core.read.ListAppender<>();
appender.start();
appLogger.addAppender(appender);
appLogger.setLevel(ch.qos.logback.classic.Level.TRACE);
}
@AfterEach
void tearDown() {
if (appLogger != null && appender != null) {
appLogger.detachAppender(appender);
appender.stop();
}
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)",
BOOTSTRAP_PHONE);
jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE);
// 恢复本用例开始前临时停用的其他 ADMIN
for (Long id : deactivatedAdmins) {
jdbcTemplate.update("UPDATE users SET is_active = 1 WHERE id = ?", id);
}
deactivatedAdmins.clear();
}
private long activeAdmins() {
return userRepository.countByRoleAndActiveTrue(User.Role.ADMIN);
}
@Test
void runnerIsAssembledWhenPropertyIsTrue() {
assertThat(runner).as("开关为 true 时应装配初始化器").isNotNull();
assertThat(properties.getNickname()).isEqualTo("引导管理员");
}
@Test
void createsAdminConsistentAcrossUsersAndUserRoles() {
runner.run(null);
List<java.util.Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, role, is_active, is_verified FROM users WHERE phone = ?", BOOTSTRAP_PHONE);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).get("role")).isEqualTo("ADMIN");
assertThat(((Number) rows.get(0).get("is_active")).intValue()).isEqualTo(1);
assertThat(((Number) rows.get(0).get("is_verified")).intValue()).isEqualTo(1);
Long id = ((Number) rows.get(0).get("id")).longValue();
List<String> roles = jdbcTemplate.queryForList(
"SELECT role FROM user_roles WHERE user_id = ?", String.class, id);
assertThat(roles).as("user_roles 必须与 users.role 一致").containsExactly("ADMIN");
assertThat(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users WHERE phone = ?", Integer.class, BOOTSTRAP_PHONE)).isEqualTo(1);
}
@Test
void repeatedRunIsIdempotent() {
runner.run(null);
long afterFirst = activeAdmins();
runner.run(null);
runner.run(null);
assertThat(activeAdmins()).as("重复启动不得创建第二个管理员").isEqualTo(afterFirst);
assertThat(countByPhone()).isEqualTo(1);
}
/**
* 并发初始化的部署契约(**方案 A**):一个线程创建成功,另一个重新查询后判定现存账号
* 正是本次配置要求的合法 ADMIN,返回**幂等成功** —— 两个调用均不泄漏异常,且最终只有
* 一条 users 与一条 ADMIN user_roles。不使用任何 JVM 锁。
*/
@Test
void concurrentBootstrapCannotCreateTwoAdmins() throws Exception {
// 前置条件:不得已有活动 ADMIN,否则初始化器会按设计跳过,本用例失去意义。
assertThat(activeAdmins()).as("并发用例前置条件:不得已有活动 ADMIN").isZero();
int threads = 2;
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch ready = new CountDownLatch(threads);
CountDownLatch finished = new CountDownLatch(threads);
List<Future<String>> outcomes = new java.util.ArrayList<>();
for (int i = 0; i < threads; i++) {
outcomes.add(pool.submit(() -> {
ready.countDown();
start.await(20, TimeUnit.SECONDS); // 屏障:两线程同时开始
try {
runner.run(null);
return "SUCCESS"; // 方案 A:创建成功或幂等成功
} catch (Throwable t) {
return "UNKNOWN:" + t.getClass().getName();
} finally {
finished.countDown();
}
}));
}
assertThat(ready.await(20, TimeUnit.SECONDS)).as("两线程都已到达屏障").isTrue();
start.countDown();
List<String> results = new java.util.ArrayList<>();
for (Future<String> f : outcomes) {
results.add(f.get(60, TimeUnit.SECONDS));
}
pool.shutdownNow();
assertThat(finished.await(5, TimeUnit.SECONDS)).as("两线程都已结束").isTrue();
long success = results.stream().filter("SUCCESS"::equals).count();
long unknown = results.stream().filter(r -> r.startsWith("UNKNOWN")).count();
// 方案 A:一个创建、一个幂等成功 —— 两个调用都必须成功,且都不得抛出异常。
assertThat(success).as("两个调用都应成功(实测 " + results + ")").isEqualTo(2);
assertThat(unknown).as("未知异常必须为 0(实测 " + results + ")").isZero();
// 最终数据状态
assertThat(countByPhone()).as("同手机号用户恰好 1 个").isEqualTo(1);
assertThat(activeAdmins()).as("活动 ADMIN 恰好 1 个").isEqualTo(1);
assertThat(adminRoleRows()).as("user_roles 中 ADMIN 恰好 1 行").isEqualTo(1);
assertThat(templateHash()).as("不得设置默认密码").isNull();
assertThat(otherRoleRows()).as("不得出现 STUDENT/COMPANY 角色").isZero();
// 异常与日志不得泄漏手机号、SQL 参数或用户对象
String log = logAppenderText();
assertThat(log).as("日志不得包含手机号").doesNotContain(BOOTSTRAP_PHONE);
assertThat(log).as("日志不得包含 SQL 参数占位").doesNotContain("bind");
assertThat(log).as("日志不得包含用户对象").doesNotContain("User(id=");
}
private int adminRoleRows() {
Integer n = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id = ur.user_id "
+ "WHERE u.phone = ? AND ur.role = 'ADMIN'", Integer.class, BOOTSTRAP_PHONE);
return n == null ? 0 : n;
}
private int otherRoleRows() {
Integer n = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id = ur.user_id "
+ "WHERE u.phone = ? AND ur.role IN ('STUDENT','COMPANY')",
Integer.class, BOOTSTRAP_PHONE);
return n == null ? 0 : n;
}
private String templateHash() {
return jdbcTemplate.queryForObject(
"SELECT password_hash FROM users WHERE phone = ?", String.class, BOOTSTRAP_PHONE);
}
private String logAppenderText() {
return appender == null ? ""
: appender.list.stream()
.map(ch.qos.logback.classic.spi.ILoggingEvent::getFormattedMessage)
.collect(java.util.stream.Collectors.joining("\n"));
}
private int countByPhone() {
Integer n = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users WHERE phone = ?", Integer.class, BOOTSTRAP_PHONE);
return n == null ? 0 : n;
}
}

237
mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapRunnerTest.java

@ -0,0 +1,237 @@
package com.mydreamjob.auth;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import com.mydreamjob.config.AdminBootstrapProperties;
import org.junit.jupiter.api.AfterEach;
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.slf4j.LoggerFactory;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* S03-A 管理员初始化的规则分支(设计 §3.2)。
*
* <p>真实 {@link AdminBootstrapRunner} + Mock 仓储,逐条覆盖任务书 §四 要求的场景。
* 日志脱敏用真实 Logback 捕获验证。</p>
*/
@ExtendWith(MockitoExtension.class)
class AdminBootstrapRunnerTest {
private static final String PHONE = "13800000009";
private static final String OTHER_PHONE = "13800000008";
@Mock private UserRepository userRepository;
/** S03 整改后创建走独立事务组件(JdbcTemplate),单测以 mock 替代。 */
@Mock private AdminBootstrapCreator creator;
private AdminBootstrapProperties properties;
private AdminBootstrapRunner runner;
private ListAppender<ILoggingEvent> appender;
private Logger appLogger;
@BeforeEach
void setUp() {
properties = new AdminBootstrapProperties();
runner = new AdminBootstrapRunner(properties, userRepository, creator);
appLogger = (Logger) LoggerFactory.getLogger("com.mydreamjob");
appender = new ListAppender<>();
appender.start();
appLogger.addAppender(appender);
appLogger.setLevel(Level.TRACE);
}
@AfterEach
void tearDown() {
if (appLogger != null && appender != null) {
appLogger.detachAppender(appender);
appender.stop();
}
}
private String capturedLog() {
return appender.list.stream().map(ILoggingEvent::getFormattedMessage).collect(Collectors.joining("\n"));
}
private void enableWith(String phone) {
properties.setEnabled("true");
properties.setPhone(phone);
}
// ── 关闭 / 未启用 ──────────────────────────────────────────
@Test
void disabledDoesNothing() {
properties.setEnabled("false");
properties.setPhone(PHONE);
runner.run(null);
verifyNoInteractions(userRepository);
verifyNoInteractions(creator);
}
@Test
void nonLiteralTrueValueDoesNotEnable() {
// 只有字面量 "true" 才算启用;大小写变体一律不生效。
for (String value : new String[]{"TRUE", "True", "yes", "1", "on", " true "}) {
properties.setEnabled(value);
properties.setPhone(PHONE);
runner.run(null);
}
verifyNoInteractions(userRepository);
verifyNoInteractions(creator);
}
// ── 手机号校验 ─────────────────────────────────────────────
@Test
void missingPhoneFailsAtStartup() {
properties.setEnabled("true");
properties.setPhone("");
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null));
assertEquals(AdminBootstrapRunner.ERROR_INVALID_PHONE, ex.getMessage());
verifyNoInteractions(userRepository);
verifyNoInteractions(creator);
}
@Test
void invalidPhoneFailsAtStartup() {
for (String bad : new String[]{"123", "1380000000", "138000000012", "1380000000a", " 1380000000a "}) {
properties.setEnabled("true");
properties.setPhone(bad);
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null));
assertEquals(AdminBootstrapRunner.ERROR_INVALID_PHONE, ex.getMessage());
}
verifyNoInteractions(userRepository);
verifyNoInteractions(creator);
}
// ── 已有活动 ADMIN ─────────────────────────────────────────
@Test
void existingActiveAdminIsSkippedWithoutSideEffects() {
enableWith(PHONE);
when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(1L);
runner.run(null);
verify(creator, never()).createAdmin(any(), any());
assertTrue(capturedLog().contains("admin bootstrap skipped"), "应输出固定跳过文本");
}
// ── 手机号已被占用 ─────────────────────────────────────────
@Test
void phoneBelongingToStudentFails() {
enableWith(PHONE);
when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L);
when(creator.findByPhone(PHONE)).thenReturn(Optional.of(
new AdminBootstrapCreator.BootstrapUser(9L, "STUDENT", true, true, true, List.of("STUDENT"))));
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null));
assertEquals(AdminBootstrapRunner.ERROR_PHONE_IN_USE, ex.getMessage());
verify(creator, never()).createAdmin(any(), any());
}
@Test
void phoneBelongingToCompanyFails() {
enableWith(PHONE);
when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L);
when(creator.findByPhone(PHONE)).thenReturn(Optional.of(
new AdminBootstrapCreator.BootstrapUser(9L, "COMPANY", true, true, true, List.of("COMPANY"))));
assertThrows(IllegalStateException.class, () -> runner.run(null));
verify(creator, never()).createAdmin(any(), any());
}
@Test
void phoneBelongingToInactiveUserFailsAndIsNotRevived() {
enableWith(PHONE);
when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L);
when(creator.findByPhone(PHONE)).thenReturn(Optional.of(
new AdminBootstrapCreator.BootstrapUser(9L, "ADMIN", false, true, true, List.of("ADMIN"))));
assertThrows(IllegalStateException.class, () -> runner.run(null));
verify(creator, never()).createAdmin(any(), any());
}
// ── 正常创建 ───────────────────────────────────────────────
@Test
void createsActiveVerifiedAdminWithConsistentRole() {
enableWith(PHONE);
when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L);
when(creator.findByPhone(PHONE)).thenReturn(Optional.empty());
when(creator.createAdmin(any(), any())).thenReturn(AdminBootstrapCreator.CreateResult.CREATED);
runner.run(null);
// 创建由独立事务组件完成(JdbcTemplate),单测只验证编排参数;
// 「users.role / user_roles 一致、active、verified、password_hash 为空」由
// AdminBootstrapIntegrationTest 与 AdminBootstrapConcurrencyLoggingTest 在真实库上断言。
verify(creator).createAdmin(eq(PHONE), any());
verify(userRepository, never()).save(any());
}
@Test
void customNicknameIsApplied() {
enableWith(PHONE);
properties.setNickname("运维管理员");
when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L);
when(creator.findByPhone(PHONE)).thenReturn(Optional.empty());
when(creator.createAdmin(any(), any())).thenReturn(AdminBootstrapCreator.CreateResult.CREATED);
runner.run(null);
verify(creator).createAdmin(PHONE, "运维管理员");
}
// ── 日志脱敏 ───────────────────────────────────────────────
@Test
void logNeverContainsPhone() {
enableWith(PHONE);
when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L);
when(creator.findByPhone(OTHER_PHONE)).thenReturn(Optional.empty());
when(creator.createAdmin(any(), any())).thenReturn(AdminBootstrapCreator.CreateResult.CREATED);
properties.setPhone(OTHER_PHONE);
runner.run(null);
String log = capturedLog();
assertThatNotContains(log, OTHER_PHONE);
assertThatNotContains(log, PHONE);
}
@Test
void failureMessagesNeverContainPhone() {
enableWith(PHONE);
when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L);
when(creator.findByPhone(PHONE)).thenReturn(Optional.of(
new AdminBootstrapCreator.BootstrapUser(9L, "STUDENT", true, true, true, List.of("STUDENT"))));
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null));
assertThatNotContains(ex.getMessage(), PHONE);
}
private static void assertThatNotContains(String haystack, String needle) {
assertFalse(haystack != null && haystack.contains(needle),
"输出不得包含手机号");
}
}

112
mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapTransactionTest.java

@ -0,0 +1,112 @@
package com.mydreamjob.auth;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.dao.DataAccessResourceFailureException;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.PlatformTransactionManager;
import javax.sql.DataSource;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.startsWith;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.spy;
/**
* S03 定向整改:{@code users} 与 {@code user_roles} 必须**同事务**提交 ——
* 角色写入失败时用户创建整体回滚,不得留下「有用户、无角色」的半个管理员。
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class AdminBootstrapTransactionTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(8600);
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private DataSource dataSource;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private JdbcTemplate jdbcTemplate;
private String phone;
@BeforeEach
void setUp() {
phone = "13" + String.format("%09d", PHONE_SEQ.incrementAndGet());
}
@AfterEach
void tearDown() {
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", phone);
jdbcTemplate.update("DELETE FROM users WHERE phone = ?", phone);
}
private int userRows() {
Integer n = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users WHERE phone = ?", Integer.class, phone);
return n == null ? 0 : n;
}
private int roleRows() {
Integer n = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id = ur.user_id WHERE u.phone = ?",
Integer.class, phone);
return n == null ? 0 : n;
}
@Test
void successfulCreateWritesUserAndRoleInSameTransaction() {
AdminBootstrapCreator creator = new AdminBootstrapCreator(jdbcTemplate, transactionManager);
assertThat(creator.createAdmin(phone, "事务管理员"))
.isEqualTo(AdminBootstrapCreator.CreateResult.CREATED);
assertThat(userRows()).as("用户恰好一行").isEqualTo(1);
assertThat(roleRows()).as("user_roles 恰好一行").isEqualTo(1);
}
@Test
void roleInsertFailureRollsBackUser() {
JdbcTemplate failing = spy(new JdbcTemplate(dataSource));
// 让 user_roles 的写入失败,users 的写入成功 —— 验证两者同事务。
doThrow(new DataAccessResourceFailureException("simulated role insert failure"))
.when(failing).update(startsWith("INSERT INTO user_roles"), any(Object[].class));
AdminBootstrapCreator creator = new AdminBootstrapCreator(failing, transactionManager);
assertThat(creator.createAdmin(phone, "回滚管理员"))
.as("角色写入失败必须返回 CONFLICT,而不是抛出携带原始信息的异常")
.isEqualTo(AdminBootstrapCreator.CreateResult.CONFLICT);
assertThat(userRows()).as("users 必须随角色失败整体回滚").isZero();
assertThat(roleRows()).isZero();
}
@Test
void concurrentCreateLeavesExactlyOneUserAndOneRole() {
AdminBootstrapCreator creator = new AdminBootstrapCreator(jdbcTemplate, transactionManager);
AdminBootstrapCreator.CreateResult first = creator.createAdmin(phone, "并发甲");
AdminBootstrapCreator.CreateResult second = creator.createAdmin(phone, "并发乙");
assertThat(List.of(first, second))
.as("第二次同号创建必须被识别为冲突").contains(AdminBootstrapCreator.CreateResult.CONFLICT);
assertThat(userRows()).as("并发后用户恰好一行").isEqualTo(1);
assertThat(roleRows()).as("并发后 ADMIN 角色恰好一行").isEqualTo(1);
}
}

223
mydreamjob-server/src/test/java/com/mydreamjob/auth/LegacySeedAccountDisableMigrationTest.java

@ -0,0 +1,223 @@
package com.mydreamjob.auth;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* S03-A {@code V43__disable_legacy_seed_accounts.sql} 的行为验证。
*
* <p>直接读取并执行迁移文件中的语句,验证:完整指纹命中、任一单字段偏差不命中、
* 非固定 ID 也能命中、重复执行幂等、关联数据保留、迁移文件本身的约束。</p>
*
* <p>V1_1 的种子账号仍占用原始手机号(唯一约束),因此用例开始前把它们移到哨兵指纹,
* 结束时原样恢复;用例自身插入的行按昵称前缀清理。</p>
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class LegacySeedAccountDisableMigrationTest {
private static final String HASH = "$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi";
private static final String TAG = "V43TEST_";
private static final List<String> SEED_PHONES = List.of("13800000001", "13800000002", "13800000003");
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private JdbcTemplate jdbcTemplate;
private String migrationSql;
private final List<Map<String, Object>> movedSeedRows = new ArrayList<>();
@BeforeEach
void setUp() throws IOException {
ClassPathResource res = new ClassPathResource("db/migration/V43__disable_legacy_seed_accounts.sql");
migrationSql = new String(res.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
cleanupOwnRows();
moveSeedRowsAside();
}
@AfterEach
void tearDown() {
cleanupOwnRows();
restoreSeedRows();
}
private String seedPhoneList() {
return SEED_PHONES.stream().map(p -> "'" + p + "'").reduce((a, b) -> a + "," + b).orElse("''");
}
/** 把 V1_1 种子行移到哨兵指纹,腾出原始手机号与邮箱。 */
private void moveSeedRowsAside() {
movedSeedRows.clear();
movedSeedRows.addAll(jdbcTemplate.queryForList(
"SELECT id, phone, email FROM users WHERE phone IN (" + seedPhoneList() + ")"));
jdbcTemplate.update(
"UPDATE users SET phone = CONCAT('SEEDMVD', id), email = CONCAT('seedmvd', id, '@example.invalid') "
+ "WHERE phone IN (" + seedPhoneList() + ")");
}
/** 把种子行恢复为最初的位置(不改动它们的 is_active 等状态)。 */
private void restoreSeedRows() {
for (Map<String, Object> row : movedSeedRows) {
jdbcTemplate.update("UPDATE users SET phone = ?, email = ? WHERE id = ?",
row.get("phone"), row.get("email"), row.get("id"));
}
movedSeedRows.clear();
}
private void cleanupOwnRows() {
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE nickname LIKE ?)", TAG + "%");
jdbcTemplate.update("DELETE FROM users WHERE nickname LIKE ?", TAG + "%");
}
/**
* 执行迁移文件中的全部 UPDATE 语句。
*
* <p>必须先逐行剥离 {@code --} 注释,否则首个语句块会以注释开头而被整体跳过
* (那样"不命中"类断言会假通过)。</p>
*/
private void runMigration() {
StringBuilder stripped = new StringBuilder();
for (String line : migrationSql.split("\n")) {
if (line.trim().startsWith("--")) {
continue;
}
stripped.append(line).append('\n');
}
for (String stmt : stripped.toString().split(";")) {
String s = stmt.trim();
if (s.isEmpty()) {
continue;
}
jdbcTemplate.update(s);
}
}
private long insertUser(String phone, String email, String hash, String role, int active, String nickname) {
jdbcTemplate.update(
"INSERT INTO users (phone, email, nickname, password_hash, role, is_active, is_verified) "
+ "VALUES (?, ?, ?, ?, ?, ?, 1)",
phone, email, nickname, hash, role, active);
Long id = jdbcTemplate.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, phone);
jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, ?)", id, role);
return id;
}
private int activeOf(long id) {
Integer v = jdbcTemplate.queryForObject("SELECT is_active FROM users WHERE id = ?", Integer.class, id);
return v == null ? -1 : v;
}
// ── 完整指纹命中 ────────────────────────────────────────────
@Test
void fullFingerprintMatchIsDisabled() {
long admin = insertUser("13800000001", "admin@mydreamjob.com", HASH, "ADMIN", 1, TAG + "admin");
long student = insertUser("13800000002", "student@mydreamjob.com", HASH, "STUDENT", 1, TAG + "student");
long company = insertUser("13800000003", "company@mydreamjob.com", HASH, "COMPANY", 1, TAG + "company");
runMigration();
assertThat(activeOf(admin)).as("ADMIN 原始指纹应被禁用").isZero();
assertThat(activeOf(student)).as("STUDENT 原始指纹应被禁用").isZero();
assertThat(activeOf(company)).as("COMPANY 原始指纹应被禁用").isZero();
}
@Test
void hitsRegardlessOfNumericId() {
long id = insertUser("13800000001", "admin@mydreamjob.com", HASH, "ADMIN", 1, TAG + "nonfixed");
assertThat(id).as("用例应使用 1/2/3 之外的 id").isNotIn(1L, 2L, 3L);
runMigration();
assertThat(activeOf(id)).isZero();
}
// ── 任一单字段偏差不命中 ────────────────────────────────────
@Test
void singleFieldDeviationIsNotDisabled() {
long phoneChanged = insertUser("13800000099", "admin@mydreamjob.com", HASH, "ADMIN", 1, TAG + "phone");
long emailChanged = insertUser("13800000001", "ops@mydreamjob.com", HASH, "ADMIN", 1, TAG + "email");
long hashChanged = insertUser("13800000002", "student@mydreamjob.com", "$2a$10$DIFFERENTHASHVALUE", "STUDENT", 1, TAG + "hash");
long roleChanged = insertUser("13800000003", "company@mydreamjob.com", HASH, "STUDENT", 1, TAG + "role");
runMigration();
assertThat(activeOf(phoneChanged)).as("改过手机号不应被禁用").isEqualTo(1);
assertThat(activeOf(emailChanged)).as("改过邮箱不应被禁用").isEqualTo(1);
assertThat(activeOf(hashChanged)).as("改过口令哈希不应被禁用").isEqualTo(1);
assertThat(activeOf(roleChanged)).as("改过角色不应被禁用").isEqualTo(1);
}
@Test
void alreadyInactiveAccountIsNotMatched() {
long id = insertUser("13800000001", "admin@mydreamjob.com", HASH, "ADMIN", 0, TAG + "inactive");
runMigration();
assertThat(activeOf(id)).as("条件含 is_active = 1,已禁用行不再命中").isZero();
}
// ── 重复执行 ────────────────────────────────────────────────
@Test
void repeatedExecutionIsStable() {
long id = insertUser("13800000001", "admin@mydreamjob.com", HASH, "ADMIN", 1, TAG + "repeat");
runMigration();
assertThat(activeOf(id)).isZero();
runMigration();
runMigration();
assertThat(activeOf(id)).as("重复执行后仍为 disabled").isZero();
Integer rows = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users WHERE phone = '13800000001' AND nickname = ?", Integer.class, TAG + "repeat");
assertThat(rows).as("不得产生或删除记录").isEqualTo(1);
}
// ── 关联数据保留 ────────────────────────────────────────────
@Test
void relatedRowsArePreserved() {
long id = insertUser("13800000003", "company@mydreamjob.com", HASH, "COMPANY", 1, TAG + "assoc");
runMigration();
Integer stillThere = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, id);
assertThat(stillThere).as("迁移不得删除用户记录").isEqualTo(1);
List<Map<String, Object>> roles = jdbcTemplate.queryForList(
"SELECT role FROM user_roles WHERE user_id = ?", id);
assertThat(roles).as("迁移不得删除 user_roles 记录").hasSize(1);
assertThat(roles.get(0).get("role")).isEqualTo("COMPANY");
}
// ── 迁移文件本身的约束 ──────────────────────────────────────
@Test
void migrationFileOnlyUpdatesIsActiveWithFiveFingerprints() {
assertThat(migrationSql).as("只允许 UPDATE users").doesNotContainIgnoringCase("DELETE FROM");
assertThat(migrationSql).doesNotContainIgnoringCase("DROP ");
assertThat(migrationSql).doesNotContainIgnoringCase("TRUNCATE");
for (String clause : new String[]{"phone =", "email =", "password_hash =", "role =", "is_active = 1"}) {
assertThat(migrationSql).as("匹配条件必须包含 " + clause).contains(clause);
}
int updates = migrationSql.split("(?i)UPDATE users").length - 1;
assertThat(updates).as("ADMIN/STUDENT/COMPANY 三条").isEqualTo(3);
}
}

15
mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokens.java

@ -100,6 +100,16 @@ public final class TestAuthTokens {
/** 返回 Access Token 原文。 */ /** 返回 Access Token 原文。 */
public static synchronized String accessToken(Long userId, String role) { public static synchronized String accessToken(Long userId, String role) {
return mintPair(userId, role).access();
}
/** 返回可通过 S02 过滤器的 Refresh Token 原文(type=refresh、refresh 密钥、真实会话)。 */
public static synchronized String refreshToken(Long userId, String role) {
return mintPair(userId, role).refresh();
}
/** 同时签发 Access/Refresh 并登记会话;返回对。 */
private static IssuedPair mintPair(Long userId, String role) {
if (LIBRARIES.isEmpty()) { if (LIBRARIES.isEmpty()) {
throw new IllegalStateException( throw new IllegalStateException(
"TestAuthTokens is not initialized: no test datasource was registered"); "TestAuthTokens is not initialized: no test datasource was registered");
@ -133,7 +143,10 @@ public final class TestAuthTokens {
throw new IllegalStateException( throw new IllegalStateException(
"TestAuthTokens could not write a session into any registered test datasource"); "TestAuthTokens could not write a session into any registered test datasource");
} }
return access.token(); return new IssuedPair(access.token(), refresh.token());
}
private record IssuedPair(String access, String refresh) {
} }
private static void writeSession(JdbcTemplate jdbc, Long userId, String role, String refreshHash, private static void writeSession(JdbcTemplate jdbc, Long userId, String role, String refreshHash,

317
mydreamjob-server/src/test/java/com/mydreamjob/config/ActuatorIsolationIntegrationTest.java

@ -0,0 +1,317 @@
package com.mydreamjob.config;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.support.TestAuthTokens;
import com.mydreamjob.auth.UserRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.server.LocalManagementPort;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.context.ActiveProfiles;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* S03-C Actuator 隔离:业务端口不提供指标,管理端口只绑定配置地址,健康探针最小化。
*
* <p>真实启动两个端口(业务随机端口 + 管理随机端口),用真实 HTTP 验证。</p>
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {
"management.server.port=0",
"management.server.address=127.0.0.1"
})
@ActiveProfiles("integration-test")
class ActuatorIsolationIntegrationTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(8700);
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@LocalServerPort
private int businessPort;
@LocalManagementPort
private int managementPort;
@Value("${management.server.address}")
private String managementAddress;
@Value("${management.endpoint.health.show-details}")
private String showDetails;
@Value("${management.endpoint.health.show-components}")
private String showComponents;
@Autowired
private UserRepository userRepository;
private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)).build();
private Long studentId;
private Long companyId;
private Long adminId;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(anyString())).thenReturn(false);
studentId = createUser(User.Role.STUDENT);
companyId = createUser(User.Role.COMPANY);
adminId = createUser(User.Role.ADMIN);
}
@AfterEach
void tearDown() {
for (Long id : new Long[]{studentId, companyId, adminId}) {
if (id != null) {
userRepository.deleteById(id);
}
}
}
private Long createUser(User.Role role) {
return userRepository.save(User.builder()
.phone("16" + String.format("%09d", PHONE_SEQ.incrementAndGet()))
.nickname("S03 actuator")
.role(role).roles(Set.of(role))
.active(true).verified(true).build()).getId();
}
private int get(int port, String path, String token) {
try {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:" + port + path))
.timeout(Duration.ofSeconds(10)).GET();
if (token != null) {
b.header("Authorization", "Bearer " + token);
}
return client.send(b.build(), HttpResponse.BodyHandlers.ofString()).statusCode();
} catch (Exception e) {
return -1;
}
}
private String body(int port, String path) {
return rawGet(port, path, null).body();
}
/** 一次真实 HTTP GET 的结果;连接层失败记为 status=-1,让断言明确失败而非抛异常。 */
private record Probe(int status, String body, String contentType) {
}
private Probe rawGet(int port, String path, String token) {
try {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:" + port + path))
.timeout(Duration.ofSeconds(10)).GET();
if (token != null) {
b.header("Authorization", "Bearer " + token);
}
HttpResponse<String> r = client.send(b.build(), HttpResponse.BodyHandlers.ofString());
return new Probe(r.statusCode(), r.body(),
r.headers().firstValue("Content-Type").orElse(""));
} catch (Exception e) {
return new Probe(-1, "", "");
}
}
// ── 业务端口 ───────────────────────────────────────────────
@Test
void businessPortDoesNotExposeActuatorEndpoints() {
for (String path : new String[]{"/actuator/metrics", "/actuator/prometheus",
"/actuator/health", "/actuator/info", "/actuator"}) {
assertThat(get(businessPort, path, null))
.as("业务端口不得提供 " + path).isEqualTo(404);
}
}
@Test
void businessTokensCannotRevealMetricsOnBusinessPort() {
String[] tokens = {
TestAuthTokens.bearer(studentId, "STUDENT"),
TestAuthTokens.bearer(companyId, "COMPANY"),
TestAuthTokens.bearer(adminId, "ADMIN"),
};
for (String token : tokens) {
for (String path : new String[]{"/actuator/metrics", "/actuator/prometheus"}) {
assertThat(get(businessPort, path, token))
.as("业务 JWT 不得让指标出现在业务端口:" + path).isEqualTo(404);
}
}
}
@Test
void businessHealthProbeStaysMinimal() throws Exception {
// 业务健康检查走自定义 /health 控制器,只含最小状态。
// 按**字段名**断言,而不是对原始 JSON 做子串匹配 —— 随机 requestId 可能恰好包含
// "db" 之类的子串,会造成假失败。
String body = body(businessPort, "/api/v1/health");
var data = new com.fasterxml.jackson.databind.ObjectMapper().readTree(body).path("data");
assertThat(data.path("status").asText()).isNotBlank();
var fields = new java.util.ArrayList<String>();
data.fieldNames().forEachRemaining(fields::add);
for (String forbidden : new String[]{"db", "redis", "rabbit", "rabbitmq", "diskSpace",
"hikari", "connectionPool", "components", "details"}) {
assertThat(fields).as("业务健康响应不得含字段 " + forbidden).doesNotContain(forbidden);
}
}
// ── 管理端口 ───────────────────────────────────────────────
@Test
void managementPortServesHealthAndMetrics() throws Exception {
String token = TestAuthTokens.bearer(adminId, "ADMIN");
// health:允许 200,或因外围依赖不可用而 503;但必须是合法 JSON 且含 status。
Probe health = rawGet(managementPort, "/actuator/health", null);
assertThat(health.status()).as("health 必须是 200 或 503(实测 " + health.status() + ")")
.isIn(200, 503);
var healthJson = new com.fasterxml.jackson.databind.ObjectMapper().readTree(health.body());
assertThat(healthJson.has("status")).as("health 响应必须含 status 字段").isTrue();
// metrics:必须 200 且返回 names 列表;-1/401/403/404/500 均不算成功。
Probe metrics = rawGet(managementPort, "/actuator/metrics", token);
assertThat(metrics.status()).as("metrics 必须 HTTP 200(实测 " + metrics.status() + ")")
.isEqualTo(200);
var metricsJson = new com.fasterxml.jackson.databind.ObjectMapper().readTree(metrics.body());
assertThat(metricsJson.has("names")).as("metrics 响应必须包含 names").isTrue();
assertThat(metricsJson.get("names").isArray()).as("names 必须是数组").isTrue();
// prometheus:必须 200,Content-Type 为 Prometheus 文本格式,正文含稳定指标名。
Probe prom = rawGet(managementPort, "/actuator/prometheus", token);
assertThat(prom.status()).as("prometheus 必须 HTTP 200(实测 " + prom.status() + ")")
.isEqualTo(200);
assertThat(prom.contentType()).as("prometheus Content-Type 必须为文本格式(实测 " + prom.contentType() + ")")
.containsIgnoringCase("text/plain");
assertThat(prom.body()).as("prometheus 正文必须含稳定指标名")
.contains("jvm_memory_used_bytes");
}
@Test
void managementPortIsIsolatedByNetworkNotByBusinessCredentials() {
// 设计决策:管理端口的运维边界是**网络隔离**(默认只绑定 127.0.0.1),
// 而不是业务 JWT 角色。因此指标在管理端口无需凭据即可读取,
// 而在业务端口上同一路径根本不存在。
assertThat(rawGet(managementPort, "/actuator/metrics", null).status())
.as("管理端口上 metrics 由网络隔离保护,无需业务凭据").isEqualTo(200);
assertThat(get(businessPort, "/actuator/metrics", null))
.as("业务端口不得提供同一端点").isEqualTo(404);
}
@Test
void managementHealthReturnsMinimalStatusOnly() {
String body = body(managementPort, "/actuator/health");
assertThat(body).as("健康响应必须含 status 字段").contains("status");
// 组件与细节一律不暴露:show-details=never / show-components=never 的实际效果
assertThat(body).as("不得返回组件明细")
.doesNotContain("components")
.doesNotContain("diskSpace")
.doesNotContain("hikari")
.doesNotContain("connectionPool")
.doesNotContain("mydreamjob-mysql");
}
@Test
void onlyWhitelistedEndpointsAreExposed() {
// 非白名单端点不得可用:既可能因未暴露而 404,也可能被安全层先行拦为 401,
// 两者都证明「不可读」。
for (String path : new String[]{"/actuator/env", "/actuator/beans",
"/actuator/configprops", "/actuator/heapdump", "/actuator/threaddump"}) {
int code = get(managementPort, path, null);
assertThat(code).as(path + " 不得可读(实测 " + code + ")").isIn(401, 404);
}
}
// ── 配置断言 ───────────────────────────────────────────────
@Test
void managementBindsLoopbackAndHealthDetailsAreNever() {
assertThat(managementAddress).as("管理端口默认只绑定回环地址").isEqualTo("127.0.0.1");
assertThat(showDetails).isEqualTo("never");
assertThat(showComponents).isEqualTo("never");
}
@Test
void managementPortDiffersFromBusinessPort() {
assertThat(managementPort).as("管理端口必须与业务端口分离").isNotEqualTo(businessPort);
}
// ── S03 整改复验:业务端口 /actuator/** 任何身份一律 404 ──────────
@Test
void businessActuatorPathsReturn404ForEveryIdentity() {
String[] paths = {
"/actuator", "/actuator/", "/actuator/health", "/actuator/health/liveness",
"/actuator/info", "/actuator/metrics", "/actuator/metrics/jvm.memory.used",
"/actuator/prometheus", "/actuator/env", "/actuator/beans",
"/actuator/configprops", "/actuator/heapdump", "/actuator/threaddump",
"/actuator/unknown"
};
String[] identities = {
null,
TestAuthTokens.bearer(studentId, "STUDENT"),
TestAuthTokens.bearer(companyId, "COMPANY"),
TestAuthTokens.bearer(adminId, "ADMIN"),
"Bearer invalid.garbage.token.value",
"Bearer " + TestAuthTokens.refreshToken(studentId, "STUDENT"),
};
for (String path : paths) {
for (String auth : identities) {
Probe p = rawGet(businessPort, path, auth);
assertThat(p.status())
.as("8080 %s(身份=%s)必须 404,不得出现 401/403/500",
path, auth == null ? "anonymous" : auth.substring(0, Math.min(11, auth.length())) + "...")
.isEqualTo(404);
assertNoLeak(p.body());
}
}
}
/** 递归扫描响应:任何异常/实现细节/路径/敏感信息都不得出现。 */
private static void assertNoLeak(String body) {
String normalized = body == null ? "" : body;
assertThat(normalized).as("不得包含异常类名/静态资源提示/框架包名")
.doesNotContain("NoResourceFoundException", "No static resource", "org.springframework",
"classpath", "stackTrace", "exception", "handler");
assertThat(normalized).as("不得包含本地路径").doesNotContain("C:", "\\Users", "\\Desktop");
assertThat(normalized.toLowerCase()).as("不得包含 Token/凭据/数据库信息")
.doesNotContain("token", "password", "jdbc");
assertThat(normalized).as("不得包含 11 位连续数字(手机号)").doesNotMatch("\\d{11}");
}
@Test
void managementUnexposedEndpointsReturn404() {
for (String path : new String[]{"/actuator/env", "/actuator/beans", "/actuator/configprops",
"/actuator/heapdump", "/actuator/threaddump", "/actuator/unknown"}) {
int code = get(managementPort, path, null);
assertThat(code).as("8081 %s 必须 404(未暴露端点,不得 401/200/403/500)", path).isEqualTo(404);
}
}
}

90
mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java

@ -1,14 +1,11 @@
package com.mydreamjob.privacy; package com.mydreamjob.privacy;
import com.mydreamjob.application.Application; import com.mydreamjob.application.CompanyResumeAccessPolicy;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.privacy.dto.PrivacySettingsResponse; import com.mydreamjob.privacy.dto.PrivacySettingsResponse;
import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest; import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@ -32,8 +29,7 @@ class PrivacySettingsServiceTest {
@Mock private StudentPrivacySettingsRepository privacyRepository; @Mock private StudentPrivacySettingsRepository privacyRepository;
@Mock private UserRepository userRepository; @Mock private UserRepository userRepository;
@Mock private ApplicationRepository applicationRepository; @Mock private CompanyResumeAccessPolicy companyResumeAccessPolicy;
@Mock private CompanyRepository companyRepository;
@Mock private AuditService auditService; @Mock private AuditService auditService;
@InjectMocks private PrivacySettingsService service; @InjectMocks private PrivacySettingsService service;
@ -205,84 +201,14 @@ class PrivacySettingsServiceTest {
// ── 隐私策略 ───────────────────────────────────────────── // ── 隐私策略 ─────────────────────────────────────────────
@Test @Test
void canCompanyViewResume_private_alwaysFalseEvenWithVerifiedCompany() { void canCompanyViewResume_delegatesToAccessPolicy() {
when(privacyRepository.findByStudentUserId(STUDENT_ID)) // S03-B:判定逻辑已收束到失败关闭的 CompanyResumeAccessPolicy,本服务只负责委托。
.thenReturn(Optional.of(settings(ResumeVisibility.PRIVATE, false))); // 完整决策矩阵(含公司归属)由 CompanyResumeAccessPolicyTest 覆盖;
assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); // 原先在此断言"已验证企业无需拥有岗位即可查看简历"的用例编码的正是被修复的缺陷,故一并移除。
} when(companyResumeAccessPolicy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)).thenReturn(true);
@Test
void canCompanyViewResume_defaultPrivate_whenNoRecord() {
when(privacyRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty());
assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID));
}
@Test
void canCompanyViewResume_appliedCompanies_validApplication_visible() {
when(privacyRepository.findByStudentUserId(STUDENT_ID))
.thenReturn(Optional.of(settings(ResumeVisibility.APPLIED_COMPANIES, false)));
Application app = Application.builder().id(APP_ID).studentUserId(STUDENT_ID).status("SUBMITTED").build();
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(app));
assertTrue(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); assertTrue(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID));
}
@Test
void canCompanyViewResume_appliedCompanies_withdrawn_notVisible() {
when(privacyRepository.findByStudentUserId(STUDENT_ID))
.thenReturn(Optional.of(settings(ResumeVisibility.APPLIED_COMPANIES, false)));
Application app = Application.builder().id(APP_ID).studentUserId(STUDENT_ID).status("WITHDRAWN").build();
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(app));
assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID));
}
@Test
void canCompanyViewResume_appliedCompanies_rejected_notVisible() {
when(privacyRepository.findByStudentUserId(STUDENT_ID))
.thenReturn(Optional.of(settings(ResumeVisibility.APPLIED_COMPANIES, false)));
Application app = Application.builder().id(APP_ID).studentUserId(STUDENT_ID).status("REJECTED").build();
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(app));
assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID));
}
@Test
void canCompanyViewResume_appliedCompanies_wrongStudent_notVisible() {
when(privacyRepository.findByStudentUserId(STUDENT_ID))
.thenReturn(Optional.of(settings(ResumeVisibility.APPLIED_COMPANIES, false)));
Application app = Application.builder().id(APP_ID).studentUserId(999L).status("SUBMITTED").build();
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(app));
assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID));
}
@Test
void canCompanyViewResume_verifiedCompanies_verifiedCompany_visible() {
when(privacyRepository.findByStudentUserId(STUDENT_ID))
.thenReturn(Optional.of(settings(ResumeVisibility.VERIFIED_COMPANIES, false)));
when(companyRepository.findById(COMPANY_ID))
.thenReturn(Optional.of(Company.builder().id(COMPANY_ID).verified(true).build()));
assertTrue(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID));
}
@Test
void canCompanyViewResume_verifiedCompanies_unverifiedCompany_notVisible() {
when(privacyRepository.findByStudentUserId(STUDENT_ID))
.thenReturn(Optional.of(settings(ResumeVisibility.VERIFIED_COMPANIES, false)));
when(companyRepository.findById(COMPANY_ID))
.thenReturn(Optional.of(Company.builder().id(COMPANY_ID).verified(false).build()));
assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID));
}
@Test
void canCompanyViewResume_verifiedCompanies_unknownCompany_notVisible() {
when(privacyRepository.findByStudentUserId(STUDENT_ID))
.thenReturn(Optional.of(settings(ResumeVisibility.VERIFIED_COMPANIES, false)));
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.empty());
when(companyResumeAccessPolicy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)).thenReturn(false);
assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID));
} }
} }
Loading…
Cancel
Save