Browse Source

feat: harden production bootstrap and resume privacy

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

24
docs/frontend-integration.md

@ -990,3 +990,27 @@ V43 仅供管理员后台使用,前端不得向学生或企业展示。当前
可用接口、参数和小样本规则见 [candidate-scoring-fairness-api.md](candidate-scoring-fairness-api.md)。前端不得根据城市、学校、姓名等字段创建敏感人群筛选,也不得尝试通过多次小样本查询反推出候选人。
总览、维度和申诉响应都显式返回 `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": {

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

@ -31,6 +31,7 @@
| C08 推荐与人才邀约 | 岗位推荐、学生授权发现、企业邀约与接受后沟通 | recommendation/、talentdiscovery/、talentinvitation/ | 默认不发现;同企业历史投递排除;限额/冷却/幂等;接受后会话隐私 | 张顺 / 小刘 |
| 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 不能刷新;二次使用撤销当前会话;并发刷新最多成功一次;登出/注销完成使会话失效 | 张顺 / 独立复验 |
| S03 生产配置与简历隐私 | 历史种子账号治理、一次性管理员初始化、企业简历下载授权、Actuator 隔离 | auth/(AdminBootstrapRunner)、application/(CompanyResumeAccessPolicy、CompanyResumeService)、V43__disable_legacy_seed_accounts.sql、application.yml | V43 只禁用完全匹配原始指纹的三条种子账号;管理员初始化默认关闭且幂等;三种可见性 × 全部投递状态 × 跨企业/未认证企业矩阵;拒绝路径零文件读取;业务端口不提供指标 | 张顺 / 独立复验 |
## 3. 一期必须补齐的八个功能组

29
docs/privacy-settings-api.md

@ -99,4 +99,31 @@
## 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 | 导出任务进行中,不可删除版本 |
| 8205 | `DUPLICATE_DEFAULT_RESUME` | 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'))
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']
@ -632,10 +632,38 @@ assert _auth_paths == {
}, f'Unexpected /auth paths: {sorted(_auth_paths)}'
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())
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(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)")

5
mydreamjob-server/pom.xml

@ -54,6 +54,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- S03-C:Actuator 暴露 prometheus 端点所需;缺少该依赖时 /actuator/prometheus 不存在。 -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- Flyway -->
<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.ErrorCode;
import com.mydreamjob.application.dto.CandidateDetailView;
import com.mydreamjob.application.dto.CompanyResumeFile;
import com.mydreamjob.application.dto.CandidateListResponse;
import com.mydreamjob.application.dto.ApplicationCreateRequest;
import com.mydreamjob.application.dto.ApplicationStatusRequest;
@ -15,11 +16,15 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -34,6 +39,7 @@ public class ApplicationController {
private final ApplicationService applicationService;
private final CompanyService companyService;
private final UserRepository userRepository;
private final CompanyResumeService companyResumeService;
private static final Set<String> ALLOWED_STATUSES = Set.of(
"SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN");
@ -204,6 +210,29 @@ public class ApplicationController {
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) {

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.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.HashMap;
import java.util.Map;
@ -296,6 +297,17 @@ public class GlobalExceptionHandler {
.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)
public ResponseEntity<ApiResponse<Void>> handleGenericException(
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\":\"\"}");
}))
.authorizeHttpRequests(auth -> auth
// Actuator 健康检查
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers("/actuator/info").permitAll()
// S03-C 整改:业务端口统一放行 /actuator/**。
// Actuator 端点仍只注册在独立管理端口(默认 127.0.0.1),业务端口上
// /actuator/** 全部落入 MVC 的不存在资源处理,由 GlobalExceptionHandler 的
// NoResourceFoundException 精确映射为 404 —— 避免匿名请求先被认证层
// 拦截成 401(未放行路径)或落入通用异常映射成 500(放行路径)。
// permitAll 不代表暴露指标,只保证所有 /actuator/** 都走到同一 404 语义;
// 业务端口不得新增任何 Actuator Controller 或端点映射。
.requestMatchers("/actuator/**").permitAll()
// Swagger UI
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**").permitAll()
// 健康检查 API

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

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

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

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

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;
import com.mydreamjob.application.Application;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.application.CompanyResumeAccessPolicy;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
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.privacy.dto.PrivacySettingsResponse;
import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest;
import org.junit.jupiter.api.BeforeEach;
@ -32,8 +29,7 @@ class PrivacySettingsServiceTest {
@Mock private StudentPrivacySettingsRepository privacyRepository;
@Mock private UserRepository userRepository;
@Mock private ApplicationRepository applicationRepository;
@Mock private CompanyRepository companyRepository;
@Mock private CompanyResumeAccessPolicy companyResumeAccessPolicy;
@Mock private AuditService auditService;
@InjectMocks private PrivacySettingsService service;
@ -205,84 +201,14 @@ class PrivacySettingsServiceTest {
// ── 隐私策略 ─────────────────────────────────────────────
@Test
void canCompanyViewResume_private_alwaysFalseEvenWithVerifiedCompany() {
when(privacyRepository.findByStudentUserId(STUDENT_ID))
.thenReturn(Optional.of(settings(ResumeVisibility.PRIVATE, false)));
assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID));
}
@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));
void canCompanyViewResume_delegatesToAccessPolicy() {
// S03-B:判定逻辑已收束到失败关闭的 CompanyResumeAccessPolicy,本服务只负责委托。
// 完整决策矩阵(含公司归属)由 CompanyResumeAccessPolicyTest 覆盖;
// 原先在此断言"已验证企业无需拥有岗位即可查看简历"的用例编码的正是被修复的缺陷,故一并移除。
when(companyResumeAccessPolicy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)).thenReturn(true);
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));
}
}
Loading…
Cancel
Save