Browse Source

feat: deliver V24-V33 backend release candidate

feat/message-recall
Zhang Shun 2 months ago
parent
commit
afd6a4570f
  1. 46
      docs/acceptance-candidate-scoring-governance.md
  2. 55
      docs/acceptance-candidate-scoring.md
  3. 240
      docs/account-deletion-api.md
  4. 161
      docs/candidate-feedback-hardening-design.md
  5. 78
      docs/candidate-scoring-api.md
  6. 64
      docs/candidate-scoring-governance-api.md
  7. 125
      docs/company-candidate-api.md
  8. 276
      docs/company-scoring-dashboard-api.md
  9. 199
      docs/competition-ranking-api.md
  10. 484
      docs/frontend-integration.md
  11. 211
      docs/interview-score-api.md
  12. 266
      docs/offer-advance-api.md
  13. 9386
      docs/openapi-rc.json
  14. 204
      docs/personal-data-export-api.md
  15. 102
      docs/privacy-settings-api.md
  16. 152
      docs/report-block-api.md
  17. 114
      docs/verify_openapi.py
  18. 531
      mydreamjob-server/docs/backend-development-roadmap.md
  19. 74
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java
  20. 13
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java
  21. 185
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java
  22. 40
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java
  23. 30
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java
  24. 24
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateListResponse.java
  25. 65
      mydreamjob-server/src/main/java/com/mydreamjob/audit/SecurityAuditService.java
  26. 19
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java
  27. 3
      mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java
  28. 74
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateDimensionScore.java
  29. 34
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateDimensionScoreRepository.java
  30. 2
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardService.java
  31. 506
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java
  32. 81
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringController.java
  33. 171
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java
  34. 102
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardController.java
  35. 530
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java
  36. 33
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/AdminDashboardOverviewResponse.java
  37. 36
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/CandidateRankingDetailResponse.java
  38. 25
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/CandidateRankingItem.java
  39. 20
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/CandidateRankingListResponse.java
  40. 25
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/DashboardOverviewResponse.java
  41. 27
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/JobRankingItem.java
  42. 20
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/JobRankingListResponse.java
  43. 31
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/ScoreTrendResponse.java
  44. 38
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoringOverviewResponse.java
  45. 31
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoringTrendResponse.java
  46. 36
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CompanyCandidateScoringResponse.java
  47. 44
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/DimensionScoreDetailResponse.java
  48. 101
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppeal.java
  49. 51
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistory.java
  50. 12
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistoryRepository.java
  51. 32
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealRepository.java
  52. 152
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceController.java
  53. 387
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java
  54. 44
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringRollbackLimit.java
  55. 16
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringRollbackLimitRepository.java
  56. 66
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringSnapshotHistory.java
  57. 15
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringSnapshotHistoryRepository.java
  58. 17
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/AppealDecisionRequest.java
  59. 22
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/AppealHistoryItem.java
  60. 34
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/AppealResponse.java
  61. 25
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/CreateAppealRequest.java
  62. 28
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/SnapshotHistoryItem.java
  63. 58
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  64. 61
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
  65. 61
      mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java
  66. 259
      mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java
  67. 78
      mydreamjob-server/src/main/java/com/mydreamjob/competition/dto/CompetitionRankingResponse.java
  68. 14
      mydreamjob-server/src/main/java/com/mydreamjob/config/AsyncConfig.java
  69. 32
      mydreamjob-server/src/main/java/com/mydreamjob/config/OpenApiSecurityConfig.java
  70. 4
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  71. 14
      mydreamjob-server/src/main/java/com/mydreamjob/config/WebMvcConfig.java
  72. 10
      mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
  73. 215
      mydreamjob-server/src/main/java/com/mydreamjob/dataexport/DataExportService.java
  74. 482
      mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java
  75. 97
      mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportBuilder.java
  76. 60
      mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportCleanupService.java
  77. 106
      mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java
  78. 114
      mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportTask.java
  79. 40
      mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportTaskRepository.java
  80. 153
      mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportWorker.java
  81. 19
      mydreamjob-server/src/main/java/com/mydreamjob/dataexport/dto/CreateDataExportRequest.java
  82. 46
      mydreamjob-server/src/main/java/com/mydreamjob/dataexport/dto/DataExportTaskResponse.java
  83. 112
      mydreamjob-server/src/main/java/com/mydreamjob/dataexport/storage/LocalPersonalDataExportStorageService.java
  84. 27
      mydreamjob-server/src/main/java/com/mydreamjob/dataexport/storage/PersonalDataExportStorageService.java
  85. 77
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletion.java
  86. 112
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java
  87. 131
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java
  88. 43
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionPhoneHasher.java
  89. 29
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionReadService.java
  90. 28
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionRepository.java
  91. 223
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionService.java
  92. 95
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java
  93. 19
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionStatus.java
  94. 48
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/dto/AccountDeletionResponse.java
  95. 19
      mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java
  96. 65
      mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java
  97. 45
      mydreamjob-server/src/main/java/com/mydreamjob/feedback/dto/CandidateFeedbackResponse.java
  98. 15
      mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewCreateRequest.java
  99. 106
      mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScore.java
  100. 89
      mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreController.java

46
docs/acceptance-candidate-scoring-governance.md

@ -0,0 +1,46 @@
# 评分治理基础模块 验收记录
> 日期:2026-08-20 · 状态:✅ 已验收(Docker/MySQL 黑盒 PASS=48 · FAIL=0 · SKIP=0)
## 1. Flyway V25 迁移结果
- 最新成功迁移 **V25(candidate scoring governance,success=1)** ✅
- H2(测试)与 MySQL(运行库)均已成功应用。
## 2. 四张治理表
- `candidate_scoring_appeals`(`uk_csa_active` active_key 唯一、`uk_csa_idem` idempotency_key 唯一、dimension/status CHECK)
- `candidate_scoring_appeal_history`(含 actor_role)
- `candidate_scoring_snapshot_history`(回滚前后 score/version/rulesVersion)
- `candidate_scoring_rollback_limits`(`rollback_year`,UNIQUE(student_user_id, rollback_year))
## 3. 黑盒结果
**PASS=48 · FAIL=0 · SKIP=0**(Docker/MySQL 真实环境)
- 学生:申诉→PENDING、列表/详情、history 脱敏、同幂等键同体重放 200、不同体 409/8607、同维度同周期 409/8602、撤销→CANCELLED、重复撤销 409/8606
- 管理员:列表、REJECT 缺 reason 400/8604、APPROVE→APPROVED、重复 APPROVE 幂等、终态改决定 409/8603、回滚→ROLLED_BACK、重复回滚幂等、history 顺序 PENDING→APPROVED→ROLLED_BACK
- 回滚限流:3 次后第 4 次 409/8605,rollback_limits 计数保持 3
- 快照审计:afterVersion = beforeVersion + 1,rulesVersion 正确
- 权限:COMPANY 全 403/2002、STUDENT 访问 admin 403/2002、跨学生 403、未登录 401
- 数据隔离:jobs/applications/offers/candidate_feedback/student_profiles/student_preferences 零改动
- 回归:既有 6 个评分接口均 HTTP 200
## 4. OpenAPI 路径数
**95 paths**(静态 docs/openapi-rc.json 与运行时 /v3/api-docs 一致;治理新增 8 paths / 9 operations,POST+GET 共享 /appeals)
## 5. 权限矩阵
| 接口 | 未登录 | STUDENT | COMPANY | ADMIN |
|---|---|---|---|---|
| 申诉 1–4 | 401 | ✅ 本人 | 403 | 403 |
| 历史(8) | 401 | ✅ 本人(脱敏) | 403 | ✅ 全量 |
| admin 5/6/7/9 | 401 | 403 | 403 | ✅ |
## 6. 回滚前后快照审计
`candidate_scoring_snapshot_history` 记录 action=ROLLBACK、before/after score、before/after version、before/after rules_version;`afterVersion = beforeVersion + 1`。
## 7. 年度回滚上限
每学生每自然年 ≤3 次;`SELECT ... FOR UPDATE` 悲观锁串行化并发;第 4 次稳定 409/8605,计数不超额。
## 8. 已修复缺陷(验收中发现)
1. **学生详情泄露 `reviewerId`**:AppealResponse 对 reviewerId 误加 `@JsonInclude(ALWAYS)`(学生端 null 也被序列化)。修复:移除 ALWAYS,null 由全局 NON_NULL 省略。复验:学生响应不含 reviewerId/actorUserId/phone/email/passwordHash/resumeUrl ✅
2. **快照 afterVersion 读到持久化上下文旧值**(before=after)。修复:afterVersion 确定性计算 `= beforeVersion + 1`(或 0)。复验:before=1、after=2 ✅
## 9. 备注
未删除业务数据;未修改 V1–V24;未修改已验收接口。治理数据仅写入 4 张治理表 + candidate_dimension_scores 新版本 + audit_logs。

55
docs/acceptance-candidate-scoring.md

@ -0,0 +1,55 @@
# Candidate Scoring Engine 验收记录(版本冻结)
> 日期:2026-08-19 · 状态:✅ **冻结全绿**(旧评分卡 MySQL 缺陷已修复并复验,见 §7)
## 1. 构建与测试
| 项 | 结果 |
|---|---|
| `mvn clean verify` | **869 tests · Failures=0 · Errors=0 · Skipped=0 · BUILD SUCCESS** ✅ |
## 2. Candidate Scoring 黑盒(重跑)
**PASS=25 · FAIL=0 · SKIP=0** ✅
- STUDENT 综合评分卡(8 维、weight 12.50、rulesVersion=candidate-scoring-v0.1、PENDING/INSUFFICIENT score=null)
- 维度明细(EDUCATION 全字段);非法维度 → 400
- trend months=6(按 period 独立归一化);months=0/25 → 400
- 企业评分卡(app 31)→ 200,响应仅白名单字段
- 越权:跨企业 403/8004、不存在 404/8002、ADMIN 403/2002、学生接口企业/管理员 403、未登录 401
## 3. 写入隔离
- 评分仅写入 `candidate_dimension_scores`(同周期 upsert、version 递增 5→6)
- jobs/applications/student_profiles/offers/candidate_feedback 行数零改动 ✅
## 4. OpenAPI
- 静态 `docs/openapi-rc.json`:**87 paths**
- 运行时 `/api/v1/v3/api-docs`:**87 paths**,路径集合与静态完全一致 ✅
## 5. Flyway
- 最新成功迁移:**V24(candidate scoring)** ✅
- `candidate_dimension_scores` 表存在,CHECK/唯一约束在位 ✅
## 6. 基础设施
- MySQL / Redis / RabbitMQ healthy;queue.notification / dlq ready=0 unacked=0 ✅
- RabbitMQ 应用连接:修复为使用系统环境变量 `RABBITMQ_VHOST` 被错误覆盖的问题(以 `$env:RABBITMQ_VHOST="/"` 启动后连接正常,无 vhost 报错)✅
## 7. 冻结发现(已修复并复验)
- 旧接口回归曾发现:`/profile/me/candidate-score-card` 在 **MySQL 上返回 HTTP 500**。
- 根因:`CandidateScoreCardService.getScoreCard` 使用 `SELECT CAST(cf.tags AS VARCHAR) ...`,**MySQL 8 不支持 `CAST(... AS VARCHAR)`**(仅 `CHAR`);H2 支持故单测/构建通过,MySQL 运行时报 `SQLSyntaxErrorException`。
- **已修复**(负责人批准,一行):`CandidateScoreCardService.java` 第 60 行 `CAST(cf.tags AS VARCHAR)` → `CAST(cf.tags AS CHAR)`。
- **复验结果**:`candidate-score-card` / `-trend` / `-percentile` 三个接口在真实 MySQL 环境均返回 **HTTP 200**;`mvn clean verify` 869 全绿。
- **冻结结论**:✅ **版本冻结宣布为全绿**(详见 §1–§6 与 §8)。
## 8. 备注
- 未修改任何已验收接口语义;未新增 Flyway;未删除业务数据。
- 后端运行方式:`mvn spring-boot:run`(最新构建,环境变量由 backend-infra/.env 注入;Windows 需显式 `$env:RABBITMQ_VHOST="/"`)。
## 9. Release Candidate 检查清单(冻结全绿)
| 检查项 | 值 | 状态 |
|---|---|---|
| `mvn clean verify` | **869 tests · 0 failures · 0 errors · 0 skipped · BUILD SUCCESS** | ✅ |
| Candidate Scoring 黑盒 | **PASS=25 · FAIL=0 · SKIP=0** | ✅ |
| OpenAPI | **87 paths**(静态 `docs/openapi-rc.json` 与运行时 `/v3/api-docs` 集合一致) | ✅ |
| Flyway | 最新 **V24**(candidate scoring) | ✅ |
| Docker | MySQL / Redis / RabbitMQ 均 healthy | ✅ |
| RabbitMQ 队列 | queue.notification、queue.notification.dlq 均 ready=0 unacked=0 | ✅ |
| 旧评分卡回归 | score-card / score-trend / percentile 均 HTTP 200 | ✅ |
| 写入隔离 | 评分仅写 `candidate_dimension_scores`;业务表零改动 | ✅ |

240
docs/account-deletion-api.md

@ -0,0 +1,240 @@
# 账号注销(Account Deletion)API 联调说明
> 模块:账号注销。当前登录用户可对自己的账号发起注销申请、查询状态、在冷静期内取消注销。
> 适用于 **STUDENT / COMPANY / ADMIN** 所有登录角色;未登录一律返回 `HTTP 401`。
## 1. 接口概览
| 方法 | 路径 | 角色 | 说明 |
|------|------|------|------|
| POST | `/profile/me/deletion` | 所有登录角色 | 申请注销当前账号(`NONE → PENDING`;PENDING 重复提交幂等返回 200;COMPLETED 再申请返回 409/9301) |
| GET | `/profile/me/deletion` | 所有登录角色 | 查询当前用户注销状态;无记录返回默认对象 `NONE`,不写库 |
| POST | `/profile/me/deletion/cancel` | 所有登录角色 | 仅在 PENDING 且未超过 `effectiveAt` 时可取消(`PENDING → NONE`) |
身份一律从 JWT / SecurityContext 获取,**不接受** `userId`、`candidateId` 等身份参数。
## 2. 注销状态机
状态仅有三种:
```text
NONE —— 正常态(未注销)
PENDING —— 冷静期(可取消;业务写操作被限制)
COMPLETED —— 终态(账号已脱敏,登录被阻止,不可恢复)
```
转换规则:
```text
NONE ----发起注销----> PENDING
PENDING ----冷静期内取消-> NONE
PENDING ----30天到期------> COMPLETED
COMPLETED(终态,不可恢复)
```
## 3. 冷静期
- 默认 **30 天**,可通过 `app.deletion.cooling-days`(环境变量 `DELETION_COOLING_DAYS`)配置。
- 发起注销时 `effectiveAt = requestedAt + 冷静期`。
- 冷静期内允许登录、允许 GET 查询,但**业务写操作被拦截**。
- 超过 `effectiveAt` 后,PENDING 会转换为 COMPLETED;此后不可取消。
## 4. 默认 NONE 行为
`GET /profile/me/deletion` 在用户**从未发起注销**时返回默认对象,且**不会自动向数据库插入记录**:
```json
{
"code": 200,
"message": "ok",
"data": {
"userId": 2,
"status": "NONE",
"version": 0
},
"timestamp": "2026-08-25T10:00:00Z"
}
```
`requestedAt / effectiveAt / cancelledAt / completedAt` 仅在对应状态下返回(无值不输出)。
## 5. 请求与响应示例
### 5.1 发起注销
```http
POST /api/v1/profile/me/deletion
Authorization: Bearer <token>
```
```json
{
"code": 200,
"message": "ok",
"data": {
"userId": 2,
"status": "PENDING",
"requestedAt": "2026-08-25T10:00:00",
"effectiveAt": "2026-09-24T10:00:00",
"version": 0
},
"timestamp": "2026-08-25T10:00:00Z"
}
```
PENDING 状态下重复提交:返回 200 当前状态,**不重复写入**`ACCOUNT_DELETION_INITIATED` 审计。
### 5.2 查询状态
```http
GET /api/v1/profile/me/deletion
Authorization: Bearer <token>
```
响应结构同默认对象;已在 PENDING 时返回上述 PENDING 结构。
### 5.3 取消注销(冷静期内)
```http
POST /api/v1/profile/me/deletion/cancel
Authorization: Bearer <token>
```
```json
{
"code": 200,
"message": "ok",
"data": {
"userId": 2,
"status": "NONE",
"cancelledAt": "2026-08-26T09:00:00",
"version": 1
},
"timestamp": "2026-08-26T09:00:00Z"
}
```
非 PENDING(如 NONE)重复取消返回当前状态,保持幂等,不重复写审计。
## 6. PENDING 写操作限制
当当前用户状态为 **PENDING** 时,`DeletionGuardInterceptor` 会拦截所有业务**写操作**
(`POST / PUT / PATCH / DELETE`),返回:
```http
HTTP/1.1 409 Conflict
Content-Type: application/json
{ "code": 9302, "message": "account deletion pending, business write is not allowed" }
```
- `GET`、`OPTIONS` 默认放行。
- 白名单放行:
- `POST /profile/me/deletion`
- `GET /profile/me/deletion`
- `POST /profile/me/deletion/cancel`
- `OPTIONS /**`
以下写接口会被拦截(示例,不止于此):`PUT /profile/me`、`PUT /profile/me/preferences`、
`POST /applications`、`POST /offers`、`POST /conversations/messages`、`PUT /profile/me/privacy`。
未注销用户、未认证请求、GET 请求**不会被误拦截**。
## 7. COMPLETED 脱敏规则与旧 JWT 处理
### 7.1 触发完成
- **定时任务**:每日扫描 `PENDING 且 effective_at <= 当前时间` 的记录,转为 COMPLETED 并脱敏。
- **懒转换**:登录、已认证请求、注销查询或写操作时,若发现 PENDING 已过期,先转换为 COMPLETED 再按 COMPLETED 规则处理。
转换与脱敏**事务化、幂等、并发安全**(`@Version` 乐观锁 + `REQUIRES_NEW` 独立事务)。
### 7.2 脱敏规则(只脱敏 `users` 表 PII,不删除任何业务数据)
| 字段 | 处理 |
|------|------|
| `phone` | 改为 `deleted_{userId}`,保证唯一 |
| `email` | 置空 |
| `nickname` | 置为 `deleted` |
| `avatarUrl` | 置空 |
| `passwordHash` | 置空 |
| `is_active` | `false` |
### 7.3 旧 JWT
COMPLETED 状态属于**终态**,即使旧 JWT 尚未过期也会被拒绝:
- 登录时校验到 COMPLETED → 阻止登录(`ACCOUNT_DELETED`)。
- 普通业务请求(即使带有效 JWT)→ `409 / 9303`。
- 注销接口专用于查询终态或返回正确错误:
- `GET /profile/me/deletion` → 可查看 `COMPLETED`;
- `POST /profile/me/deletion` → `409 / 9301`;
- `POST /profile/me/deletion/cancel` → `409 / 9301`(或 9305)。
未注销用户的 JWT 登录流程不受影响。
### 7.4 懒转换首次查询即返回 COMPLETED
懒转换(到期 PENDING → COMPLETED)在独立事务(`REQUIRES_NEW`)中完成并提交。触发懒转换后,
`GET /profile/me/deletion` 会在**独立只读事务(新快照)**中重读最新记录,因此**第一次 GET 就返回
`status=COMPLETED` 与 `completedAt`**,不会返回过期的 `PENDING`。随后任何 GET 均保持一致状态,
不会出现"第一次 PENDING、第二次 COMPLETED"的不一致。
### 7.5 原手机号禁止重新注册(墓碑)
- 用户完成注销(COMPLETED)时,在将 `users.phone` 改为 `deleted_{userId}` **之前**,
使用服务端密钥(配置:`app.deletion.phone-hash-secret` / 环境变量 `DELETION_PHONE_HASH_SECRET`)
对原手机号做 **HMAC-SHA256**,仅把摘要写入 `account_deletions.deleted_phone_hash`(唯一索引)。
- **不**保存原手机号明文到注销表,**不**写入审计日志。
- 登录/注册流程对输入手机号计算同样 HMAC 并查询 `deleted_phone_hash`:命中即拒绝 ——
`409 / 9303 ACCOUNT_DELETED`,**不自动创建新账号**。
- 已注销账号仍以 `deleted_<id>` 作为 `users.phone`(原脱敏规则不变)。
- 既不允许通过原手机号恢复原账号,也不允许通过原手机号重新注册新账号。
- 数据库变更由 **V29** 迁移完成(仅新增 `deleted_phone_hash` 字段,不改动 V1-V28 及状态机)。
## 8. 不删除业务数据
脱敏仅修改 `users` 表。以下业务数据一律**保留**:
`applications`、`jobs`、`offers`、`interviews`、`messages`、`notifications`、`reviews`、
`candidate_dimension_scores`、`audit_logs` 及其他业务数据。不物理删除任何业务数据。
## 9. 错误的角色权限矩阵
| 状态/角色 | STUDENT | COMPANY | ADMIN | 未登录 |
|-----------|:-------:|:-------:|:-----:|:------:|
| 申请注销(POST deletion) | 200 / 409(9301) | 200 / 409(9301) | 200 / 409(9301/9306) | 401 |
| 查询状态(GET deletion) | 200 | 200 | 200 | 401 |
| 取消注销(POST cancel) | 200 / 409(9305) | 200 / 409(9305) | 200 / 409(9305) | 401 |
- **ADMIN**:若为系统中**最后一个有效 ADMIN**(`role=ADMIN 且 is_active=true` 计数 ≤ 1),不允许注销,返回 `409 / 9306 LAST_ADMIN_CANNOT_DELETE`;存在多个有效 ADMIN 时允许其中一个注销。
## 10. 错误码(9301-9306)
| 业务码 | HTTP | 枚举 | 说明 |
|--------|------|------|------|
| 9301 | 409 | `INVALID_DELETION_STATE` | 状态不允许该操作(如 COMPLETED 再申请 / COMPLETED 取消恢复) |
| 9302 | 409 | `ACCOUNT_DELETION_PENDING` | 冷静期内业务写操作被拦截 |
| 9303 | 409 | `ACCOUNT_DELETED` | 账号已注销(COMPLETED),业务请求被拒 |
| 9304 | 409 | `ACCOUNT_DELETION_CONCURRENT_UPDATE` | 乐观锁并发冲突 |
| 9305 | 409 | `ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED` | 已超过冷静期,无法取消 |
| 9306 | 409 | `LAST_ADMIN_CANNOT_DELETE` | 最后一个有效 ADMIN 不能注销 |
## 11. 审计日志
写入以下 action:
- `ACCOUNT_DELETION_INITIATED`
- `ACCOUNT_DELETION_CANCELLED`
- `ACCOUNT_DELETION_COMPLETED`
- `ACCOUNT_DATA_DESENSITIZED`
细节(details)仅记录:`userId`、状态变化、时间、操作类型。**禁止**写入
`phone / email / password / passwordHash / token / jwt / resumeUrl / 验证码`。
同一次完成流程不会重复写完成审计与脱敏审计。
## 12. 并发与幂等
- `user_id` 唯一约束保证同一用户仅一条注销记录。
- POST deletion 在 PENDING 时幂等返回 200。
- cancel 在 NONE/COMPLETED 时幂等返回当前状态或明确错误。
- 使用 `@Version` 乐观锁;并发状态迁移只有一个事务成功,后到者捕获 `OptimisticLockingFailureException` 映射为 `9304`。

161
docs/candidate-feedback-hardening-design.md

@ -0,0 +1,161 @@
# CandidateFeedback 响应脱敏与契约修复设计
> 状态:已批准实施
>
> 目标:修复候选人反馈响应中的内部身份字段泄露,不改变既有接口语义。
## 1. 背景
`candidate_feedback` 模块已经提供企业创建/修改/删除反馈、企业和学生查看反馈的能力。当前服务层使用 `Map<String, Object>` 拼装响应,其中包含 `companyUserId` 和 `studentUserId`。这两个字段是内部身份标识,不属于前端页面所需的业务字段,也不应出现在学生或企业响应中。
本模块只做响应层脱敏,不修改数据库结构、请求格式、权限模型或业务流程。
## 2. 范围
### 2.1 本次实施
- 新增 `CandidateFeedbackResponse` 白名单 DTO;
- `POST/GET/PUT /applications/{applicationId}/feedback` 改为返回该 DTO;
- 从响应中移除内部用户 ID 和其他敏感字段;
- 更新 Controller 测试、Service 测试和 OpenAPI response schema;
- 更新前端联调文档;
- 执行专项测试、全量测试和 OpenAPI 校验。
### 2.2 明确不做
- 不修改 `candidate_feedback` 表;
- 不新增 Flyway;
- 不修改请求 DTO;
- 不修改 URL、HTTP 方法、权限、错误码和状态机;
- 不把 CandidateFeedback 改造成结构化面试评分;
- 不修改评分引擎对 `candidate_feedback` 的读取;
- 不启动或停止 Docker;
- 不删除业务数据。
## 3. 现有接口契约
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| POST | `/applications/{applicationId}/feedback` | 岗位所属 COMPANY | 创建反馈,同一投递只能创建一次 |
| GET | `/applications/{applicationId}/feedback` | 岗位所属 COMPANY 或投递本人 STUDENT | 查询反馈 |
| PUT | `/applications/{applicationId}/feedback` | 岗位所属 COMPANY | 修改反馈 |
| DELETE | `/applications/{applicationId}/feedback` | 岗位所属 COMPANY | 删除反馈 |
身份始终从 JWT/SecurityContext 获取,不接受 `userId`、`studentUserId` 或 `companyUserId` 请求参数。
## 4. 新响应 DTO
类名:`com.mydreamjob.feedback.dto.CandidateFeedbackResponse`
字段:
| 字段 | 类型 | 是否返回 | 说明 |
|---|---|---:|---|
| `id` | Long | 是 | 反馈 ID |
| `applicationId` | Long | 是 | 投递 ID |
| `rating` | Integer | 是 | 1–5 分 |
| `content` | String | 是 | 反馈内容 |
| `tags` | List&lt;String&gt; | 是 | 标签数组,无标签时返回空数组 |
| `createdAt` | LocalDateTime | 否 | 创建时间,未生成时省略 |
| `updatedAt` | LocalDateTime | 否 | 更新时间,未生成时省略 |
响应示例:
```json
{
"code": 200,
"message": "ok",
"data": {
"id": 1,
"applicationId": 31,
"rating": 5,
"content": "沟通良好",
"tags": ["沟通良好"],
"createdAt": "2026-08-25T10:00:00",
"updatedAt": "2026-08-25T10:00:00"
}
}
```
响应禁止出现:
```text
companyUserId
studentUserId
phone
email
passwordHash
resumeUrl
```
DTO 使用 `@JsonInclude(JsonInclude.Include.NON_NULL)`;`tags` 由服务层解析为数组,非法或空 JSON 返回空数组,不向客户端暴露原始存储内容。
## 5. 权限与错误码
| 场景 | HTTP | 业务码 |
|---|---:|---:|
| 企业正常创建/修改/删除 | 200 | 200 |
| 企业或本人学生正常查询 | 200 | 200 |
| 未登录 | 401 | 401 |
| 非岗位所属企业写入 | 403 | 2002 |
| 非本人学生查询 | 403 | 2002 |
| COMPANY/ADMIN 无企业归属或跨企业访问 | 403 | 2002 |
| 投递不存在 | 404 | 8002 |
| 反馈不存在 | 404 | 9104 |
| 重复创建 | 409 | 9103 |
| 参数校验失败 | 400 | 1001 |
本次不新增错误码,也不调整既有错误码映射。
## 6. 实现约束
1. `CandidateFeedbackService` 的创建、查询、修改方法返回 `CandidateFeedbackResponse`;
2. Service 内新增安全的 `toResponse` 映射方法;
3. 不再使用包含内部 ID 的 `Map<String, Object>` 作为接口响应;
4. Controller 使用 `ApiResponse<CandidateFeedbackResponse>`;
5. Entity 继续保留 `companyUserId`、`studentUserId`,因为它们是数据库和评分聚合所需字段;
6. 评分引擎和 Candidate Score Card 只读仓储,不受本次响应 DTO 变化影响。
## 7. 测试矩阵
### Service 测试
- 创建成功时 DTO 含业务字段;
- DTO 不含内部用户 ID;
- 查询成功时 DTO 脱敏;
- 修改成功时 DTO 脱敏;
- 标签 JSON 正常解析;
- 标签为空或非法 JSON 时返回空数组;
- 企业、学生、其他学生、其他企业权限保持原语义。
### MockMvc 测试
- POST/GET/PUT 返回 200;
- DELETE 原有行为不变;
- 响应中断言禁止字段不存在;
- 未登录返回 401;
- 参数非法返回 400;
- 越权访问返回 403;
- 反馈不存在返回 404;
- 重复创建返回 409。
### 回归测试
- Candidate Score Card 读取反馈摘要不受影响;
- `mvn test` 全量通过;
- `mvn clean verify` 全量通过;
- `python docs/verify_openapi.py` 通过;
- OpenAPI 路径数量保持 107。
## 8. 完成标准
只有以下条件全部满足,才允许进入隐私设置模块:
1. 代码编译通过;
2. CandidateFeedback 专项测试通过;
3. 全量测试通过;
4. OpenAPI 静态校验通过;
5. 响应脱敏断言通过;
6. 既有权限和错误码回归通过;
7. 未新增数据库迁移;
8. 未修改已验收接口语义。

78
docs/candidate-scoring-api.md

@ -0,0 +1,78 @@
# 求职者评分体系 API
> 求职者评分体系(Candidate Scoring Engine)v0.1 · 规则版本 `candidate-scoring-v0.1`
> 基础路径:`/api/v1` · 全部接口需 `Authorization: Bearer <accessToken>`
## 概述
8 个确定性评分维度:`EDUCATION`、`INTERNSHIP`、`ABILITY`、`COMPLIANCE`、`STABILITY`、`GROWTH`、`INTEGRITY`、`ACTIVITY`。
- 全部为**确定性规则 + 统计计算**,不接入 AI / SHAP / 公平性 / 防刷算法。
- 评分查询会写入 `candidate_dimension_scores` 快照(原生 `INSERT ... ON DUPLICATE KEY UPDATE` 批量 upsert),**不修改任何业务表**。
- 八维默认权重均为 `12.50`,参与计算的维度重新归一化(overallScore = 可计算维度算术平均)。
- 分数精度:`BigDecimal`,scale=2,`HALF_UP`;无可计算维度时 `overallScore=null`。
## 维度状态
| 状态 | 含义 |
|---|---|
| `OK` | 输入齐备,按规则确定性计算 |
| `ESTIMATED` | 部分输入缺失,使用兜底值并标注估算(如学校未知、偏好字段部分填写) |
| `INSUFFICIENT_DATA` | 关键输入缺失,不计算(score=null) |
| `PENDING` | 数据源未就绪(INTERNSHIP 无实习表、ABILITY 无测评),score=null |
综合卡 `status`:全部 8 维可计算→`OK`;部分可计算→`PARTIAL`;无可计算且存在 PENDING→`PENDING`;否则→`INSUFFICIENT_DATA`。
## 接口列表
### 1. GET `/profile/me/candidate-scoring`(仅 STUDENT 本人)
返回综合评分卡:`candidateId`、`status`、`overallScore`、`weights`、`dimensions[]`、`rulesVersion`、`generatedAt`。
### 2. GET `/profile/me/candidate-scoring/dimensions/{dimension}`(仅 STUDENT 本人)
返回单维度明细:`dimension`、`score`、`status`、`subScores[]`、`reasons[]`、`dataSources[]`、`updatedAt`、`rulesVersion`。非法 `dimension` → 400/1001。
### 3. GET `/profile/me/candidate-scoring/trend?months=6`(仅 STUDENT 本人)
返回快照月度趋势(`months` 1–24,越界 400/1001)。每个 `period` 仅取该期 `OK/ESTIMATED` 维度重新归一化计算 `overallScore`,并携带该期 `rulesVersion`。
### 4. GET `/company/candidates/{applicationId}/scoring`(仅岗位所属企业)
企业只读评分卡。**仅返回** `applicationId`、`overallScore`、`status`、`dimensions[]{dimension,score,status}`、`generatedAt`、`rulesVersion`;**禁止返回** `studentUserId`、`candidateId`、`phone`、`email`、`resumeUrl`、`sub_scores` 细节、内部日志。
## 权限矩阵
| 接口 | 未登录 | STUDENT | COMPANY | ADMIN |
|---|---|---|---|---|
| 学生 3 接口 | 401 | ✅ 本人 | 403 | 403 |
| 企业评分 | 401 | 403 | ✅(岗位归属) | 403 |
企业归属校验:`companyRepository.findByOwnerUserId(jwtUserId)` → 无企业 403/2002 → `company.id.equals(job.companyId)` 不一致 403/8004 → application 不存在 404/8002。禁止比较 userId 与 companyId。
## 八维数据来源
| 维度 | 数据来源 |
|---|---|
| EDUCATION | student_profiles.education/school(学校档次用内置名单,未知→ESTIMATED 兜底 50) |
| INTERNSHIP | 数据源未就绪 → PENDING |
| ABILITY | 能力测评未实现 → PENDING |
| COMPLIANCE | student_preferences(薪资/城市/用工/行业/岗位类型 5 字段加权;remoteAllowed 不计分) |
| STABILITY | users.createdAt(账号年龄)、applications(投递/撤回率)、audit_logs(近 30 天 LOGIN) |
| GROWTH | resume_versions(ACTIVE 数量/最近更新)、student_profiles.skillsJson(当前值,无历史) |
| INTEGRITY | offers/offer_status_history(ACCEPTED/EXPIRED)、candidate_feedback、资料完整度(student_profiles + ACTIVE 简历) |
| ACTIVITY | applications(近 90 天投递)、audit_logs(近 30 天活跃)、messages(发信) |
## 快照与趋势
- 表 `candidate_dimension_scores`:唯一约束 `(student_user_id, dimension, period)`;同周期覆盖、`version+1`;score/dimension/status 带 CHECK 约束。
- 趋势只读快照历史,不写快照;冷启动期通常仅当前月,随评分调用累积。
## 查询次数
- 综合卡:3 次实体读(user/profile/preference)+ 5 次 RowMapper 聚合(applications/audit/offers/feedback/resume)+ 1 次消息计数 + 1 次快照批量 upsert;全部单学生过滤,无 N+1,不按 8 维循环查库。
- 明细:同上(一次性采集,选单维);趋势:快照按月聚合 1 条。
## 错误码
复用:`BAD_REQUEST(1001)`、`UNAUTHORIZED(2001)`、`FORBIDDEN(2002)`、`APPLICATION_NOT_FOUND(8002)`、`APPLICATION_OWNER_MISMATCH(8004)`。不新增错误码。

64
docs/candidate-scoring-governance-api.md

@ -0,0 +1,64 @@
# 评分治理 API(Candidate Scoring Governance)
> 规则版本 `candidate-scoring-v0.1` · 基础路径 `/api/v1`
> 为求职者评分提供申诉、审核、回滚、审计基础能力;**不改已验收评分接口与引擎规则**。
## 回滚语义
回滚 = ① 记录回滚前快照(`candidate_scoring_snapshot_history`);② 按**当前数据**确定性重算(复用 `CandidateScoringCalculator`);③ 写入 `candidate_dimension_scores` 新版本;④ 记录回滚后快照;⑤ 申诉状态置 `ROLLED_BACK`;⑥ 写申诉历史与审计。**不恢复旧分数**——是"批准申诉后的确定性重新计算"。
## 接口(9 个接口落在 8 个路径,OpenAPI 87 → 95)
| # | 接口 | 方法 | 角色 |
|---|---|---|---|
| 1 | `/profile/me/candidate-scoring/appeals` | POST | STUDENT 本人(提交申诉) |
| 2 | `/profile/me/candidate-scoring/appeals` | GET | STUDENT 本人(我的列表) |
| 3 | `/profile/me/candidate-scoring/appeals/{appealId}` | GET | 本人 / ADMIN |
| 4 | `/profile/me/candidate-scoring/appeals/{appealId}/cancel` | POST | STUDENT 本人(撤销,仅 PENDING) |
| 5 | `/admin/candidate-scoring/appeals` | GET | ADMIN |
| 6 | `/admin/candidate-scoring/appeals/{appealId}/decision` | POST | ADMIN |
| 7 | `/admin/candidate-scoring/appeals/{appealId}/rollback` | POST | ADMIN |
| 8 | `/admin/candidate-scoring/appeals/{appealId}/history` | GET | 本人(脱敏)/ ADMIN(全量) |
| 9 | `/admin/candidate-scoring/snapshots/{candidateUserId}/{dimension}/{period}/history` | GET | ADMIN |
## 请求/响应
- `CreateAppealRequest`:`dimension`(8 枚举)、`period`(yyyy-MM)、`reason`(必填,缺 → 8604)、`idempotencyKey`(可选)。
- `AppealDecisionRequest`:`decision`(APPROVE/REJECT)、`reason`(REJECT 必填 → 8604)。
- `AppealResponse`:appealId、candidateId、dimension、period、status、reason、decision、decisionReason、reviewerId(学生端不返回)、reviewedAt、rollbackAt、scoreAtAppeal、snapshotVersionAtAppeal、rulesVersion、createdAt、updatedAt。
- 历史项:`actorRole`(STUDENT/ADMIN,学生端可见);`actorUserId`(**仅 ADMIN**)。
- 快照审计项:回滚前后 `score/version/rulesVersion` 完整变化(仅 ADMIN)。
## 权限矩阵
| 接口 | 未登录 | STUDENT | COMPANY | ADMIN |
|---|---|---|---|---|
| 申诉 1–4 | 401 | ✅ 本人 | 403 | 403 |
| 历史(8) | 401 | ✅ 本人(脱敏) | 403 | ✅ 全量 |
| admin 5/6/7/9 | 401 | 403 | 403 | ✅ |
身份一律取自 JWT;学生仅能访问自己的申诉;COMPANY 全 403/2002;ADMIN 的 `candidateUserId` 路径参数仅 ADMIN 可用。
## 状态机
```
PENDING ──> APPROVED ──> ROLLED_BACK(终态)
│ └────────────> REJECTED(终态)
└────────────> CANCELLED(终态)
```
## 并发与幂等
- `active_key = {candidate}:{dimension}:{period}` 唯一约束保护 PENDING/APPROVED;终态清空 active_key 允许重新提交。
- 同 idempotencyKey 同请求体 → 200 幂等;不同请求体 → **409/8607**;并发创建唯一冲突 → **409/8602**。
- 审核 `@Version` 乐观锁,并发冲突 → 409/8606。
- 回滚限流 `candidate_scoring_rollback_limits` + `SELECT ... FOR UPDATE` 悲观锁;每学生每自然年 ≤3 次,第 4 次 **409/8605**;重复回滚幂等 200。
## 错误码(新增 86xx)
| code | HTTP | 含义 |
|---|---|---|
| 8601 | 404 | 申诉不存在 |
| 8602 | 409 | 同维度+周期已有活跃申诉 |
| 8603 | 409 | 申诉已决定 |
| 8604 | 400 | 缺少理由 |
| 8605 | 409 | 年度回滚次数超限(3) |
| 8606 | 409 | 非法状态流转 / 并发冲突 |
| 8607 | 409 | 幂等键冲突(请求体不同) |
## 脱敏
学生端响应不返回:reviewerId、actorUserId、phone、email、passwordHash、resumeUrl、内部评分日志。ADMIN 可见 reviewerId 与 actorUserId。

125
docs/company-candidate-api.md

@ -0,0 +1,125 @@
# 企业候选人看板 API
## 1. 企业候选人列表
```
GET /api/v1/company/candidates?[jobId][&status][&keyword][&sort][&order][&page][&size]
```
### 请求参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| jobId | Long | 否 | 全部 | 按岗位筛选 |
| status | String | 否 | 全部 | SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN |
| keyword | String | 否 | — | 学生姓名/学校/学历关键词搜索 |
| sort | String | 否 | createdAt | createdAt/updatedAt |
| order | String | 否 | desc | asc/desc |
| page | int | 否 | 0 | 页码 (0-based) |
| size | int | 否 | 20 | 每页条数 (1-100) |
### 权限
`COMPANY` 角色,从 JWT 获取用户 → 查 Company → 用 companyId 过滤数据。
### 响应
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [{
"applicationId": 1,
"jobId": 10,
"jobTitle": "Java 后端工程师",
"status": "SUBMITTED",
"candidateName": "张三",
"candidateSchool": "清华大学",
"candidateEducation": "本科",
"candidateGraduationYear": 2026,
"candidateAvatarUrl": "https://...",
"createdAt": "2026-08-15T10:00:00",
"updatedAt": "2026-08-16T14:00:00"
}],
"total": 156,
"page": 0,
"size": 20,
"totalPages": 8
},
"requestId": "abc-123",
"timestamp": "2026-08-17T15:00:00Z"
}
```
### 错误码
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 400 | 1001 | 参数非法 (page<0, size<1或>100, 无效status/sort/order, keyword>100, jobId<=0) |
| 401 | — | 未认证 |
| 403 | 2002 | 非 COMPANY 角色 |
| 404 | 5001 | 企业不存在 |
---
## 2. 企业候选人详情
```
GET /api/v1/company/candidates/{applicationId}
```
### 权限
`COMPANY` 角色,且该投递对应的岗位必须属于当前企业。
### 响应
```json
{
"code": 200,
"message": "ok",
"data": {
"applicationId": 1,
"jobId": 10,
"jobTitle": "Java 后端工程师",
"jobCity": "北京",
"jobEmploymentType": "全职",
"status": "SUBMITTED",
"coverLetter": "尊敬的HR...",
"createdAt": "2026-08-15T10:00:00",
"updatedAt": "2026-08-16T14:00:00",
"candidateName": "张三",
"candidateAvatarUrl": "https://...",
"candidateBio": "热爱编程...",
"candidateLocation": "北京",
"candidateSchool": "清华大学",
"candidateEducation": "本科",
"candidateGraduationYear": 2026,
"candidateSkills": ["Java", "Spring Boot", "MySQL"]
},
"requestId": "abc-123",
"timestamp": "2026-08-17T15:00:00Z"
}
```
### 错误码
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 400 | 1001 | 参数非法 |
| 401 | — | 未认证 |
| 403 | 2002 | 非 COMPANY 角色 |
| 403 | 8004 | 投递不属于本企业 |
| 404 | 5001 | 企业不存在 |
| 404 | 8002 | 投递不存在 |
---
## 安全说明
- companyId 从 JWT 推导,不接受请求参数
- 所有数据限定在 `jobs.company_id = :companyId`,天然隔离
- 不返回 `studentUserId`、`phone`、`passwordHash`、`email`、原始 `resumeUrl`
- 列表不返回 `coverLetter`,详情返回 `coverLetter`
- 详情使用单次 JOIN 查询 (applications + jobs + student_profiles + users),不产生 N+1

276
docs/company-scoring-dashboard-api.md

@ -0,0 +1,276 @@
# 企业评分运营看板 API
> 企业评分运营看板 / 评分排名 / 评分趋势 v0.1
> 基础路径:`/api/v1` · 全部接口需 `Authorization: Bearer <accessToken>`
## 概述
- **只读聚合**:仅查询 `jobs` / `applications` / `job_reviews` / `job_review_dimension_scores` / `candidate_dimension_scores` 等业务表,**不写入任何业务表**、不产生新快照。
- **候选人按学生去重**:同一学生多次投递视为 1 名候选人,代表投递取该企业下该学生 `applicationId` 最大的一条;`WITHDRAWN` / `REJECTED` 投递排除在统计外。
- **候选人综合分复用** `CandidateScoringCalculator.overallScoreFromSnapshots`:读取每个候选人 `<= asOfPeriod`(当前月)的最新期维度快照,用引擎公式计算;无可计算维度时 `overallScore=null`。
- **岗位评价过滤与 `JobReviewService.getReviewSummary` 口径一致**:`status='PUBLISHED'` 且 `moderation_status IN ('AUTO_APPROVED','APPROVED')`。
- **企业数据隔离**:`companyId` 从 JWT 推导(`companyRepository.findByOwnerUserId`),所有 SQL 限定 `company_id`,不接受请求参数传入企业 ID。
## 接口列表
| # | 方法 | 路径 | 角色 | 说明 |
|---|------|------|------|------|
| 1 | GET | `/company/scoring/dashboard/overview` | COMPANY | 企业评分运营概览 |
| 2 | GET | `/company/scoring/dashboard/job-rankings?page&size` | COMPANY | 岗位评分排名(分页) |
| 3 | GET | `/company/scoring/dashboard/job-rankings/{jobId}` | COMPANY | 单岗位评分排名明细 |
| 4 | GET | `/company/scoring/dashboard/candidate-rankings?page&size` | COMPANY | 候选人评分排名(按学生去重,分页) |
| 5 | GET | `/company/scoring/dashboard/candidate-rankings/{applicationId}` | COMPANY | 单候选人评分排名明细 |
| 6 | GET | `/company/scoring/dashboard/trend?months=6` | COMPANY | 企业评分趋势(按月) |
| 7 | GET | `/admin/scoring/dashboard/overview` | ADMIN | 全局运营概览 |
## 权限矩阵
| 接口 | 未登录 | STUDENT | COMPANY | ADMIN |
|------|--------|---------|---------|-------|
| 企业看板 6 接口 | 401 | 403 | ✅(本企业) | 403 |
| 全局概览 | 401 | 403 | 403 | ✅ |
企业归属校验(单条详情):
- 岗位不存在 → `JOB_NOT_FOUND(7001)` → 404;
- 岗位不属于本企业 → `JOB_OWNER_MISMATCH(7002)` → 400;
- 投递不存在 → `APPLICATION_NOT_FOUND(8002)` → 404;
- 投递岗位不属于本企业 → `APPLICATION_OWNER_MISMATCH(8004)` → 403。
## 1. 企业评分运营概览
```
GET /company/scoring/dashboard/overview
```
### 响应
```json
{
"code": 200,
"message": "ok",
"data": {
"companyId": 1,
"totalJobs": 5,
"publishedJobs": 3,
"avgJobScore": 4.50,
"totalCandidates": 3,
"avgCandidateScore": 80.00,
"scoreDistribution": { "1": 0, "2": 0, "3": 1, "4": 2, "5": 2 },
"updatedAt": "2026-08-21T10:00:00"
}
}
```
- `totalJobs`:本企业岗位总数;`publishedJobs`:`PUBLISHED` 岗位数。
- `avgJobScore`:审核通过评价的均值(无评价 → `null`)。
- `totalCandidates`:管线内去重候选人(含无分);`avgCandidateScore`:有分候选人综合分均值(无 → `null`)。
- `scoreDistribution`:1–5 星评价分布,全键恒存在(无评价为 0)。
## 2. 岗位评分排名
```
GET /company/scoring/dashboard/job-rankings?page=0&size=20
```
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| page | int | 否 | 0 | 页码 (0-based) |
| size | int | 否 | 20 | 每页条数 (1–100) |
`page<0` 或 `size<1 || size>100` → `400 / 1001`。
排序:`avgRating DESC, jobId DESC`(无评价岗位 `avgRating=null` 排在末尾);`rank` 为 1-based,`cohortSize` 为本企业岗位总数。
### 响应
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [{
"jobId": 10,
"title": "Java 后端工程师",
"city": "北京",
"employmentType": "FULL_TIME",
"avgRating": 4.50,
"reviewCount": 12,
"rank": 1,
"cohortSize": 5,
"dimensionAverages": { "AUTHENTICITY": 4.50, "TEAM_ATMOSPHERE": 4.20 }
}],
"total": 5,
"page": 0,
"size": 20,
"totalPages": 1
}
}
```
`dimensionAverages`:审核通过评价的八维均值(`dimension_code → avg(score)`,scale 2)。
## 3. 单岗位评分排名明细
```
GET /company/scoring/dashboard/job-rankings/{jobId}
```
返回结构与列表项一致(`rank` 在本企业内计算)。跨企业 → `400 / 7002`。
## 4. 候选人评分排名
```
GET /company/scoring/dashboard/candidate-rankings?page=0&size=20
```
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| page | int | 否 | 0 | 页码 (0-based) |
| size | int | 否 | 20 | 每页条数 (1–100) |
`page<0` 或 `size<1 || size>100` → `400 / 1001`。
排序:`overallScore DESC, applicationId DESC`;`percentile` 在有分候选人集合内计算(升序百分位 = `小于自己的人数 / (有分人数-1) × 100`,scale 2,`overallScore=null` → `null`)。
### 响应
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [{
"applicationId": 30,
"jobId": 10,
"candidateName": "张三",
"candidateSchool": "清华大学",
"overallScore": 90.00,
"percentile": 100.00,
"sampleCount": 3
}],
"total": 3,
"page": 0,
"size": 20,
"totalPages": 1
}
}
```
- `applicationId` 为**代表投递**(该学生在企业下最大 `applicationId`);`candidateName`/`candidateSchool` 取 `student_profiles`。
- `sampleCount`:有分候选人总数(不分页)。
## 5. 单候选人评分排名明细
```
GET /company/scoring/dashboard/candidate-rankings/{applicationId}
```
- 投递不存在 → `404 / 8002`;投递岗位不属于本企业 → `403 / 8004`;投递状态为 `WITHDRAWN`/`REJECTED` → `404 / 8002`。
- 若该候选人快照缺失,仍返回其档案信息,`overallScore`/`percentile` 为 `null`。
### 响应
```json
{
"code": 200,
"message": "ok",
"data": {
"applicationId": 30,
"jobId": 10,
"candidateName": "张三",
"candidateSchool": "清华大学",
"overallScore": 90.00,
"percentile": 88.00,
"cohortSize": 3,
"dimensionScores": [
{ "dimension": "COMPLIANCE", "score": 100.00, "status": "OK" }
]
}
}
```
`dimensionScores`:该候选人最新期快照的各维度(按维度名升序);仅含 `dimension / score / status`,不暴露 `subScores` 与内部日志。
## 6. 企业评分趋势
```
GET /company/scoring/dashboard/trend?months=6
```
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| months | int | 否 | 6 | 统计月数,1–24 |
`months` 越界(<1 或 >24)→ `400 / 1001`。
- 岗位评价趋势:按 `job_reviews.created_at` 年月分组(审核通过评价)求均值。
- 候选人综合分趋势:读取范围内各期快照,用 `overallScoreFromSnapshots` 计算每期综合分后求均值(`candidateCount` 为该期有分候选人数)。
- 仅返回有数据的月份;`avgJobScore`/`avgCandidateScore` 某期无数据时该期缺失(不补 null 空期)。
### 响应
```json
{
"code": 200,
"message": "ok",
"data": {
"companyId": 1,
"months": 6,
"items": [
{ "period": "2026-08", "avgJobScore": 4.50, "avgCandidateScore": 80.00, "candidateCount": 2 }
]
}
}
```
## 7. 全局运营概览(ADMIN)
```
GET /admin/scoring/dashboard/overview
```
### 响应
```json
{
"code": 200,
"message": "ok",
"data": {
"totalCompanies": 12,
"totalJobs": 45,
"totalCandidates": 220,
"avgCandidateScore": 70.00,
"topCompanies": [{
"companyId": 3,
"companyName": "示例企业",
"avgCandidateScore": 88.50,
"jobCount": 5
}]
}
}
```
- `totalCandidates`:全平台管线内去重候选人(排除 `WITHDRAWN`/`REJECTED`)。
- `avgCandidateScore`:全平台有分候选人综合分均值。
- `topCompanies`:按候选人综合分均值排序的前 10 家企业。
## 错误码
复用既有错误码,不新增:
| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 400 | 1001 | 参数非法(page/size/months 越界) |
| 400 | 7002 | 岗位不属于本企业 |
| 401 | — | 未登录(安全过滤器) |
| 403 | 2002 | 非 COMPANY/ADMIN 角色、无企业资料 |
| 403 | 8004 | 投递岗位不属于本企业 |
| 404 | 4001 | 用户不存在(ADMIN 概览) |
| 404 | 7001 | 岗位不存在 |
| 404 | 8002 | 投递不存在或投递已撤回/被拒 |
## 安全说明
- 全部为 GET 只读;不返回 `studentUserId`、`phone`、`email`、`resumeUrl`、`passwordHash`。
- `companyId` 一律从 JWT 推导并限定 `jobs.company_id`,天然隔离企业数据。
- 候选人排名/趋势不写快照、不触发评分计算,仅读最新期已存在快照。

199
docs/competition-ranking-api.md

@ -0,0 +1,199 @@
# 同岗位竞争力排名 API(V32)
> 版本:V32 设计锁定版
> 状态:已编码实现(含状态口径修复)
> 适用范围:学生端只读接口,不修改既有接口、百分位、评分引擎与企业看板。
> ⚠️ 状态口径(最终):
> **投递已 WITHDRAWN 或 REJECTED 的学生已退出候选人池,访问排名接口返回 403/2002。**
> “进入排名接口”的有效投递状态与 cohort 完全一致:仅 `SUBMITTED / VIEWED / INTERVIEW / HIRED` 允许进入。
## 1. 定位与边界
为已投递某岗位的 STUDENT 提供该岗位候选人池内的竞争力排名。与既有接口边界:
| 接口 | 是否改动 | 关系 |
|---|---|---|
| `GET /profile/me/candidate-percentile` | 不改 | 全局 cohort、基于 candidate_feedback 均分;本接口为单岗位、基于候选评分快照 |
| `GET /profile/me/candidate-scoring` | 不改 | 复用其真实公式 `overallScoreFromSnapshots` |
| `GET /company/.../scoring/dashboard/candidate-rankings` | 不改 | 企业端全公司视角(含姓名/学校);本接口学生端、匿名 |
| `GET /jobs/{jobId}/match` | 不改 | 偏好确定性匹配诊断;本接口为候选人间竞争排名 |
## 2. 接口
```
GET /api/v1/jobs/{jobId}/competition-ranking
```
- 角色:仅 STUDENT。
- 身份从 JWT 获取,不接收 `userId`/`studentUserId` 参数。
- 新增 1 条 GET 路径,OpenAPI 116 → 117。
## 3. 权限矩阵
| 身份/场景 | HTTP | code | 说明 |
|---|---|---|---|
| 未登录 | 401 | 2001 | SecurityConfig `/jobs/**` 为 authenticated |
| COMPANY / ADMIN | 403 | 2002 | Service 层 `assertStudentRole` |
| STUDENT 已投递该岗位且状态有效(SUBMITTED/VIEWED/INTERVIEW/HIRED) | 200 | — | 正常返回 |
| STUDENT 未投递该岗位 | 403 | 2002 | 岗位存在但不属于其候选人池,不泄露候选人数据 |
| STUDENT 唯一投递为 WITHDRAWN 或 REJECTED | 403 | 2002 | 已退出候选人池;与 cohort 排除口径一致,不返回 200 |
| jobId 为空或 <=0 | 400 | 1001 | BAD_REQUEST |
| 岗位不存在 | 404 | 7001 | JOB_NOT_FOUND |
| 岗位非 PUBLISHED(DRAFT/CLOSED 等) | 404 | 7001 | 不暴露岗位是否存在候选人池 |
不新增错误码,复用既有 ErrorCode。
## 4. 响应结构
```
ApiResponse<CompetitionRankingResponse>
CompetitionRankingResponse {
Long jobId;
Integer rank; // 本人名次(完整 cohort 内,含无分者排序);本人无分 → null
Integer cohortSize; // cohort 总人数(含无分者)
BigDecimal overallScore; // 本人真实综合分;无有效维度 → null
BigDecimal percentile; // 百分位,仅对有分 cohort 计算;有分者<2 → null
String status; // OK / PARTIAL / INSUFFICIENT_DATA / PENDING(复用引擎语义)
String asOfPeriod; // 本次排名计算截止月份 yyyy-MM(当前月)
String mySnapshotPeriod;// 当前学生实际使用的最新快照月份
List<DimensionItem> dimensions; // 本人各维度 {dimension,score,status,weight}
List<String> gaps; // 缺口建议(基于维度状态,简单实现)
List<RankingSnapshot> topCandidates; // 匿名 Top 5
String rulesVersion;
Instant generatedAt;
}
DimensionItem { String dimension; BigDecimal score; String status; BigDecimal weight; }
RankingSnapshot { Integer rank; BigDecimal overallScore; }
```
### 明确不返回字段
- 不返回 `candidateId`(本人内部标识,前端不需要)。
- `topCandidates[]` 每个元素仅 `{rank, overallScore}`。
## 5. Cohort 口径
| 问题 | 决策 |
|---|---|
| 排除状态 | 排除 `WITHDRAWN`、`REJECTED` |
| 计入状态 | 计入 `SUBMITTED`、`VIEWED`、`INTERVIEW`、`HIRED` |
| 同学生多次投递 | 由 `applications(job_id, student_user_id)` 唯一约束天然去重,单岗位至多 1 条 |
| 是否要求已投递 | 必须存在**有效投递**(状态非 `WITHDRAWN`/`REJECTED`);仅 WITHDRAWN/REJECTED → 403/2002 |
| 无评分候选人 | 计入 cohortSize,排在最后,不参与 percentile |
| 岗位可查范围 | 仅 `PUBLISHED`;其余 → 404/7001 |
## 6. 排名规则
候选人排序(**Java 侧排序,SQL 只负责批量查询,不使用 MySQL NULLS LAST**):
```
1. 有分候选人优先(overallScore != null)
2. 有分者内部:overallScore DESC
3. overallScore 相同:application.createdAt ASC
4. 仍相同:student_user_id ASC
5. 无分候选人(overallScore == null)最后
```
- 本人 `rank`:在完整 cohort 排序中的位次;本人无分 → `rank=null`。
- 本地用 `Comparator` 排序,明确把 `overallScore == null` 最后处理。
- `ownFlag` **不参与排名排序**,仅用于在排序后定位当前学生。
## 7. 综合分与状态
- 复用 `CandidateScoringCalculator#overallScoreFromSnapshots`(真实公式,禁止 SQL 复算)。
- `status` 复用 Candidate Scoring Engine 状态语义:`OK / PARTIAL / INSUFFICIENT_DATA / PENDING`。
- 不得把 `PENDING/INSUFFICIENT_DATA` 当作 0 分。
## 8. 百分位
严格复用企业看板公式:
```
分母 = 有分候选人数 - 1
分子 = 综合分严格低于当前学生的有分候选人数
percentile = 分子 / 分母 * 100
```
- 用 `BigDecimal`,`scale=2`,`RoundingMode.HALF_UP`。
- 有分候选人 < 2 时:`percentile = null`。
## 9. 周期
- `asOfPeriod` = 当前月份 `yyyy-MM`。
- 每个候选人取自己 `<= asOfPeriod` 的最新周期快照(cohort 各候选人的最新快照月份可能不同)。
- 当前学生单独返回 `mySnapshotPeriod`。
## 10. Gaps
- 不新增另一套缺口计算规则。
- 不把 `PENDING/INSUFFICIENT_DATA` 转成 0。
- 当前阶段返回基于维度状态的简单缺口;若后续需要复杂建议另行设计。
## 11. 匿名化硬约束
`topCandidates[]` 仅返回 `{rank, overallScore}`。禁止返回任何其他候选人的:
- `studentUserId`
- `candidateId`
- `name`
- `school`
- `phone`
- `email`
- `resumeUrl`
- `applicationId`
- 其他任何身份字段
## 12. 数据库与 Flyway
- 不需要新表。
- 不新增 Flyway V32。
- 无 DDL、无数据变更。
## 13. 查询与 N+1 风险
沿用企业看板已验收的“批量 JOIN 最新期快照”模式:
1. `applications` 按 `job_id` + 状态过滤取 cohort(1 条查询)。
2. `candidate_dimension_scores` 按 `student_user_id IN (...)` 且 `period <= asOfPeriod` 批量读取,Java 侧取每个学生最新周期(1 条查询)。
3. jobs 验存在 + 用户身份读取(各 1 条)。
无 N+1,恒定时复杂度。
## 14. Top N
固定返回 Top 5,不增加 `top` 参数,避免通过参数批量探测候选人排名。分页需求另行设计。
## 15. 测试(设计)
### MockMvc
- 权限矩阵(未登录/COMPANY/ADMIN/已投递/未投递/岗位不存在/非 PUBLISHED/jobId<=0)。
- 本人有分:rank/cohortSize/percentile/overallScore 数值正确(HALF_UP、scale2)。
- 本人无分:score=null、rank=null、percentile=null、仍返回 cohortSize。
- 无分候选人排最后且计入 cohort;同分按 createdAt ASC 稳定排序。
- `topCandidates[]` 匿名化(断言响应不含身份字段)。
- 最新期快照读取(两期数据取一期)。
- cohort 状态过滤(排除 WITHDRAWN/REJECTED,计入 SUBMITTED/VIEWED/INTERVIEW/HIRED)。
### Docker 黑盒
1. 起基础设施 + 最新 JAR;`/health=200`;Flyway 无新迁移;OpenAPI=117。
2. mock 登录 STUDENT;选 PUBLISHED 岗位且 ≥2 位非无效候选人。
3. 本人 GET → 校验各字段;抓体验证 topCandidates 匿名。
4. 越权:COMPANY/ADMIN→403/2002;未投递→403/2002;匿名→401;岗位不存在或非 PUBLISHED→404/7001;jobId=0→400/1001。
5. 数据库核验最新期快照、业务数据无改动、无新表。
6. 回归:`mvn test`、`mvn clean verify`、`verify_openapi.py`。
## 16. 文档变更
- 新增:`docs/competition-ranking-api.md`(本文档)。
- 更新:`docs/openapi-rc.json`(117 paths)、`docs/verify_openapi.py`、`docs/frontend-integration.md`。
## 17. 禁止事项(实现阶段)
- 不修改 Candidate Scoring Engine。
- 不修改 percentile。
- 不修改已验收接口(评分、看板、治理、面试、通知、匹配、Offer)。
- 不新增数据库表、不新增 Flyway。
- 不启停 Docker(黑盒阶段除外)。

484
docs/frontend-integration.md

@ -25,6 +25,8 @@ $env:DB_USERNAME = "mydreamjob"
$env:DB_PASSWORD = "<你的数据库密码>" $env:DB_PASSWORD = "<你的数据库密码>"
$env:RABBITMQ_USERNAME = "mydreamjob_dev" $env:RABBITMQ_USERNAME = "mydreamjob_dev"
$env:RABBITMQ_PASSWORD = "<你的 RabbitMQ 密码>" $env:RABBITMQ_PASSWORD = "<你的 RabbitMQ 密码>"
# 注意:本机 Windows 环境变量 RABBITMQ_VHOST 可能被设置为无关路径,必须显式覆盖为默认 vhost
$env:RABBITMQ_VHOST = "/"
$env:JWT_SECRET = "<至少256位的随机密钥>" $env:JWT_SECRET = "<至少256位的随机密钥>"
$env:SMS_MOCK_ENABLED = "true" $env:SMS_MOCK_ENABLED = "true"
mvn spring-boot:run -Dspring-boot.run.profiles=dev mvn spring-boot:run -Dspring-boot.run.profiles=dev
@ -120,11 +122,11 @@ Authorization: Bearer <accessToken>
| HTTP | 业务 code | 含义 | | HTTP | 业务 code | 含义 |
|------|-----------|------| |------|-----------|------|
| 401 | 2001 | 未登录 / token 失效 | | 401 | 401(安全过滤器) | 未登录 / 无 token |
| 403 | 2002 | 无角色权限 / 越权 | | 403 | 2002, 8004, 8303, 8402 | 无角色权限 / 越权(含企业归属不匹配) |
| 400 | 1001, 3002-3006, 8004... | 参数错误 | | 400 | 1001, 3002-3006, 8404-8408, 8505, 9105 | 参数错误 / 非法请求体 / 资源未命中默认映射 |
| 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 9105 | 资源不存在 | | 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 8305, 8501 | 资源不存在 |
| 409 | 5003, 7003, 8001, 8003, 9101, 9103 | 重复 / 非法状态流转 | | 409 | 5003, 7003, 8001, 8003, 8301-8302, 8304, 8502-8504, 9101, 9103 | 重复 / 非法状态流转 |
| 500 | 1002, 9001 | 服务器内部错误 | | 500 | 1002, 9001 | 服务器内部错误 |
**前端策略**:优先判断 HTTP 状态码,再读取 `body.code` 显示业务信息。 **前端策略**:优先判断 HTTP 状态码,再读取 `body.code` 显示业务信息。
@ -181,3 +183,475 @@ Authorization: Bearer <accessToken>
### 8.3 岗位对比(公开) ### 8.3 岗位对比(公开)
`GET /jobs/compare?ids=1,2,3`(无需登录,2-3 个公开岗位),返回公开字段与公开评分。 `GET /jobs/compare?ids=1,2,3`(无需登录,2-3 个公开岗位),返回公开字段与公开评分。
## 9. Candidate Scoring(求职者评分体系)联调
规则版本固定 `rulesVersion = "candidate-scoring-v0.1"`;八维 `EDUCATION / INTERNSHIP / ABILITY / COMPLIANCE / STABILITY / GROWTH / INTEGRITY / ACTIVITY`,默认权重各 12.50,参与计算维度重新归一化。
### 9.1 接口
| 接口 | 方法 | 角色 | 说明 |
|------|------|------|------|
| `/profile/me/candidate-scoring` | GET | STUDENT 本人 | 综合评分卡(8 维 + overallScore + weights + status) |
| `/profile/me/candidate-scoring/dimensions/{dimension}` | GET | STUDENT 本人 | 单维度明细(subScores/reasons/dataSources);非法 dimension → 400 |
| `/profile/me/candidate-scoring/trend?months=6` | GET | STUDENT 本人 | 月度趋势(months 1–24,越界 400) |
| `/company/candidates/{applicationId}/scoring` | GET | COMPANY(岗位归属) | 企业只读评分卡 |
### 9.2 权限矩阵
| 接口 | 未登录 | STUDENT | COMPANY | ADMIN |
|------|--------|---------|---------|-------|
| 学生 3 接口 | 401 | ✅ 本人 | 403 | 403 |
| 企业评分 | 401 | 403 | ✅(岗位归属) | 403 |
- 企业归属:`companyRepository.findByOwnerUserId(jwtUserId)` 无企业→403/2002;`company.id != job.companyId`→403/8004;application 不存在→404/8002。
### 9.3 企业响应脱敏字段(禁止展示)
企业评分响应**仅含** `applicationId / overallScore / status / dimensions[]{dimension,score,status} / generatedAt / rulesVersion`;**不得出现** `studentUserId / candidateId / phone / email / resumeUrl / subScores / 内部日志`。
### 9.4 状态与错误码
- 维度状态:`OK / ESTIMATED / INSUFFICIENT_DATA / PENDING`;PENDING/INSUFFICIENT_DATA 的 `score` 为 `null`。
- 综合卡状态:`OK / PARTIAL / INSUFFICIENT_DATA / PENDING`;无可计算维度时 `overallScore=null`。
- 错误码复用:`BAD_REQUEST(1001)`、`UNAUTHORIZED(2001)`(未登录实际 401)、`FORBIDDEN(2002)`、`APPLICATION_NOT_FOUND(8002)`、`APPLICATION_OWNER_MISMATCH(8004)`。
### 9.5 前端注意
- 学生端先调综合卡,按 `status` 决定是否渲染 `overallScore`;PENDING/INSUFFICIENT 维度显示"数据不足/待补充"。
- 企业端评分卡仅用于候选人详情展示,勿透传任何候选人联系方式或简历地址。
## 10. 企业评分运营看板联调
全部只读聚合;候选人按学生去重,综合分复用评分引擎公式;岗位评价口径为「审核通过 + PUBLISHED」。详见 [docs/company-scoring-dashboard-api.md](company-scoring-dashboard-api.md)。
### 10.1 接口
| 接口 | 方法 | 角色 | 说明 |
|------|------|------|------|
| `/company/scoring/dashboard/overview` | GET | COMPANY | 企业评分运营概览(岗位/评价/候选人统计) |
| `/company/scoring/dashboard/job-rankings?page&size` | GET | COMPANY | 岗位评分排名(avgRating DESC, jobId DESC) |
| `/company/scoring/dashboard/job-rankings/{jobId}` | GET | COMPANY | 单岗位评分排名明细(跨企业 400/7002) |
| `/company/scoring/dashboard/candidate-rankings?page&size` | GET | COMPANY | 候选人评分排名(按学生去重,overallScore DESC) |
| `/company/scoring/dashboard/candidate-rankings/{applicationId}` | GET | COMPANY | 单候选人评分排名明细(跨企业 403/8004) |
| `/company/scoring/dashboard/trend?months=6` | GET | COMPANY | 企业评分趋势(岗位评价 + 候选人综合分,按月) |
| `/admin/scoring/dashboard/overview` | GET | ADMIN | 全局运营概览(全平台统计 + 企业 Top10) |
### 10.2 权限矩阵
| 接口 | 未登录 | STUDENT | COMPANY | ADMIN |
|------|--------|---------|---------|-------|
| 企业看板 6 接口 | 401 | 403 | ✅(本企业) | 403 |
| 全局概览 | 401 | 403 | 403 | ✅ |
### 10.3 关键口径
- 候选人去重:同一学生多次投递视为 1 名候选人,代表投递取企业下 `applicationId` 最大一条;`WITHDRAWN`/`REJECTED` 排除。
- 综合分:复用 `CandidateScoringCalculator`,读取候选人 `<= 当前月` 最新期快照计算;无可计算维度 → `overallScore=null`。
- 岗位评价:`status=PUBLISHED` 且 `moderation_status ∈ {AUTO_APPROVED, APPROVED}`(与评价汇总口径一致)。
- `percentile`:在有分候选人中计算(升序),无分 → `null`;`sampleCount` 为有分候选人数。
- 分页参数:`page`(0 起)、`size`(1–100),`months`(1–24,默认 6)。
### 10.4 前端注意
- 看板为只读统计,勿依赖实时刷新频次;趋势冷启动期通常只有当前月数据,随评分调用/评价累积逐渐补齐。
- 候选人排名项不返回联系方式与简历地址,仅返回姓名/学校/综合分/百分位/维度分(维度分仅在详情接口)。
- `avgJobScore`/`avgCandidateScore`/`overallScore` 无数据时返回 `null`,前端按「暂无数据」处理。
## 11. Offer 进阶(还价 / 对比 / 统计)联调
在基础 Offer 之上新增薪资洽谈、Offer 对比、企业统计,并扩展 `WITHDRAWN` 撤销状态。
完整字段/错误码见 [docs/offer-advance-api.md](offer-advance-api.md)。OpenAPI 总路径数 102 → **107**。
### 11.1 接口
| 接口 | 方法 | 角色 | 说明 |
|------|------|------|------|
| `/offers/{id}/counter-offers` | POST | STUDENT 本人 | 发起还价(SENT 时自动进入 NEGOTIATING) |
| `/offers/{id}/counter-offers` | GET | STUDENT 本人 / COMPANY(本企业) | 还价列表 |
| `/offers/{id}/counter-offers/{coId}/accept` | PATCH | COMPANY(本企业) | 接受还价 → Offer 成交(ACCEPTED) |
| `/offers/{id}/counter-offers/{coId}/reject` | PATCH | COMPANY(本企业) | 拒绝还价 → Offer 保持 NEGOTIATING |
| `/offers/compare?ids=1,2,3` | GET | STUDENT 本人 | 对比本人 2-5 个非 DRAFT Offer |
| `/offers/statistics?months=6` | GET | COMPANY | 企业 Offer 统计(状态分布/接受率/响应时长) |
### 11.2 还价要点
- 仅 `SENT/NEGOTIATING` 可发起;候选人发起后 Offer 自动进入 `NEGOTIATING`,前端应刷新 Offer 状态。
- 同一 Offer 同时最多一个 PENDING 还价;二次发起返回 409 `OFFER_COUNTER_OFFER_EXISTS`。
- 企业 `accept` 后会更新 Offer 薪资/入职日期并置为 `ACCEPTED`;`reject` 后 Offer 仍为 `NEGOTIATING`,候选人可再次还价。
### 11.3 对比要点
- `ids` 为逗号分隔且 2-5 个、全为正数;**非法参数一律 400**,前端需先本地校验避免请求失败。
- 返回项附带 `companyName`;被比较 Offer 含 `DRAFT` 视为 403 `OFFER_COMPARE_NOT_OWNER`,故只选已发送/洽谈中 Offer。
### 11.4 统计要点
- 仅企业可访问;`months` 1-24,越界按默认 6 处理。
- `acceptanceRate` / `avgResponseHours` 无数据时为 `null`,前端按「暂无数据」处理;`byStatus` 恒含全部 7 个状态键。
### 11.5 撤销(WITHDRAWN)
- 企业 `PATCH /offers/{id}/status` `{"status":"WITHDRAWN"}`,仅 `SENT/NEGOTIATING` 有效;其他状态 409。
- 撤销/过期会使该 Offer 的 PENDING 还价变为 `EXPIRED`,Offer 自身释放"唯一有效 Offer"槽位;企业端应刷新 Offer + 还价列表。
### 11.6 新增错误码(已并入统一映射)
| 错误码 | HTTP | 含义 |
|---|---|---|
| 8109 | 409 | 已存在 PENDING 还价 |
| 8110 | 404 | 还价不存在/不属于该 Offer |
| 8111 | 409 | 还价非 PENDING |
| 8113 | 409 | Offer 已撤销/过期,还价失效 |
| 8114 | 400 | Offer 状态不允许还价 |
| 8115 | 400 | 对比数量需 2-5 个 |
| 8116 | 403 | 对比含非本人/非有效 Offer |
## 12. 学生个人资料 GET /profile/me 联调
### 12.1 基础信息
| 项 | 值 |
|----|-----|
| Base URL | `http://127.0.0.1:8080/api/v1` |
| 状态 | 已实现、已验收(运行时黑盒 18/18 PASS) |
| 接口 | `GET /api/v1/profile/me` |
| 认证 | 必须携带 `Authorization: Bearer <accessToken>` |
| Token 获取 | 通过登录接口(`POST /auth/login`,开发环境为 Mock 验证码)获取 `accessToken`;**文档不提供任何真实凭据** |
| 限流 | 当前未设置接口级限流 |
> `accessToken` 有效期 24h。Token 只通过登录流程获得,切勿在文档、代码或日志中硬编码或打印真实 Token。
### 12.2 请求参数
- **无 Path 参数**:路径不含任何 `{id}` 等占位符。
- **无 Query 参数**:不读取任何查询串参数。
- **无 Body**:不解析请求体。
- **不接受身份参数**:身份仅从 JWT / SecurityContext 获取,`userId`、`studentUserId` 等入参一律忽略。
- 例如请求 `GET /api/v1/profile/me?userId=999`,后端仍返回 **当前 JWT 身份** 对应的资料,`userId=999` **不会改变** JWT 身份。
### 12.3 角色权限说明
| 调用方 | HTTP | body.code | body.message |
|--------|------|-----------|--------------|
| STUDENT | 200 | `200` | `ok` |
| COMPANY | 403 | `2002` | `forbidden` |
| ADMIN | 403 | `2002` | `forbidden` |
| 未登录 / Token 缺失 / 非法 / 过期 | 401 | `401` | `not authenticated` |
### 12.4 成功响应示例(有资料)
```json
{
"code": 200,
"message": "ok",
"data": {
"id": 1,
"userId": 2,
"realName": "张三",
"education": "本科",
"school": "某某大学",
"graduationYear": 2026,
"resumeUrl": "/uploads/resumes/2/resume.pdf",
"skills": "[Java, Spring Boot]",
"createdAt": "2026-08-24T10:00:00"
},
"timestamp": "2026-08-24T10:00:00Z"
}
```
> 成功 `code=200`(**不是 0**)。`null` 字段不序列化。
### 12.5 空资料响应示例
```json
{
"code": 200,
"message": "ok",
"data": {},
"timestamp": "2026-08-24T10:00:00Z"
}
```
- `data={}` 是**空对象,不是 `null`**。
- 有资料查询也**不会**返回 HTTP 404。
- 不会自动创建 `student_profiles` 记录,不修改数据库。
- 前端必须对 `data` 进行**空对象判定**(`Object.keys(data).length === 0`),而非仅判非 null。
### 12.6 401 响应示例
```json
{
"code": 401,
"message": "not authenticated",
"timestamp": "2026-08-24T10:00:00Z"
}
```
### 12.7 403 响应示例
```json
{
"code": 2002,
"message": "forbidden",
"data": null,
"timestamp": "2026-08-24T10:00:00Z"
}
```
### 12.8 业务码说明表
| 场景 | HTTP | code | message |
|------|------|------|---------|
| 成功 | 200 | `200` | `ok` |
| 未登录 / Token 无效 | 401 | `401` | `not authenticated` |
| 非 STUDENT 角色 | 403 | `2002` | `forbidden` |
> - 成功码是 `200`,**不要写成 `0`**。
> - 本接口未登录错误为 `401`,**不要写成 `2001`**。运行时认证入口实际返回 `code=401`,以运行时契约为准。
### 12.9 响应字段表(StudentProfileResponse,null 不输出)
| 字段 | 类型 | 是否返回 | 说明 | 示例 |
|------|------|----------|------|------|
| id | Long | 有资料时 | 学生资料 ID | `1` |
| userId | Long | 有资料时 | 当前登录用户 ID | `2` |
| realName | String | 可空 | 真实姓名 | `张三` |
| bio | String | 可空 | 个人简介 | — |
| location | String | 可空 | 所在地 | — |
| education | String | 可空 | 学历 | `本科` |
| school | String | 可空 | 学校 | `某某大学` |
| graduationYear | Short/Integer | 可空 | 毕业年份(四位) | `2026` |
| resumeUrl | String | 可空 | 简历地址 | `/uploads/resumes/2/resume.pdf` |
| skills | String | 可空 | 技能(字符串) | `[Java, Spring Boot]` |
| createdAt | date-time | 有资料时 | 创建时间 | `2026-08-24T10:00:00` |
> 不返回 `passwordHash`、手机号、验证码、JWT、内部认证字段。
### 12.10 skills 特别说明
- 当前类型为 **`String`**,**不保证是合法的 JSON**。
- 前端**不要无条件 `JSON.parse(skills)`**,解析失败会抛异常。
- 示例 `"[Java, Spring Boot]"` 只是普通字符串内容,不代表数组表示法。
- 后续若改为数组,必须作为**独立版本或兼容字段变更**处理,不能静默破坏现有消费方。
- **本轮不修改 `skills` 类型,也不修改 PUT 语义。**
### 12.11 resumeUrl 特别说明
- 值可能是**相对存储路径**或文件存储地址,**不保证是完整的 `http/https` URL**。
- 前端**下载简历应调用 `GET /api/v1/profile/me/resume`**,不要直接拼接 `resumeUrl` 做请求地址。
### 12.12 graduationYear 特别说明
- 当前后端**没有实现**年份范围校验。
- 文档**不得虚构**未实现的范围限制(如 1900–2100 之类)。
- 前端若展示年份,可自行决定是否校验,但接口本身不做约束。
## 13. 候选人反馈响应脱敏
候选人反馈接口保持原有路径、请求、权限和错误码不变:
```text
POST /applications/{applicationId}/feedback
GET /applications/{applicationId}/feedback
PUT /applications/{applicationId}/feedback
DELETE /applications/{applicationId}/feedback
```
成功响应中的 `data` 仅包含以下业务字段:`id`、`applicationId`、`rating`、`content`、`tags`、`createdAt`、`updatedAt`。
学生只能查看自己的投递反馈;岗位所属企业可以创建、查看、修改和删除反馈;其他学生、其他企业和管理员不因用户 ID 巧合而获得访问权限。
响应示例:
```json
{
"code": 200,
"message": "ok",
"data": {
"id": 1,
"applicationId": 31,
"rating": 5,
"content": "沟通良好",
"tags": ["沟通良好"],
"createdAt": "2026-08-25T10:00:00",
"updatedAt": "2026-08-25T10:00:00"
}
}
```
前端不得依赖或透传以下字段;这些字段不会出现在反馈响应中:
```text
companyUserId、studentUserId、phone、email、passwordHash、resumeUrl
```
`candidate_feedback` 数据表仍保留企业用户和学生用户关联字段供权限校验和评分聚合使用,但这些内部字段不属于 API 响应。CandidateFeedback 也不等同于结构化面试评分;面试评分将在独立模块中提供。
## 14. 隐私设置(Privacy Settings)联调
学生隐私设置用于控制简历可见性与联系方式可见性,仅 STUDENT 角色可用:
```text
GET /profile/me/privacy
PUT /profile/me/privacy
```
- 身份仅从 JWT 获取,不接受 `userId` / `studentUserId` / `candidateId` 等参数。
- GET 无记录时返回默认对象(`resumeVisibility=PRIVATE`、`contactVisible=false`、`version=0`),不写库。
- PUT 执行 upsert,同一学生始终一行;`version` 冲突返回 `409 / 9201`。
- COMPANY / ADMIN → `403 / 2002`;未登录 → `401`。
- 企业候选人详情/列表接口保持原脱敏白名单,不返回 `resumeUrl` 与联系方式,本轮不新增联系方式字段。
详见 [docs/privacy-settings-api.md](privacy-settings-api.md)。
## 15. 账号注销(Account Deletion)联调
当前登录用户可对自己的账号发起注销申请、查询状态、在冷静期内取消,适用于 STUDENT / COMPANY / ADMIN:
```text
POST /profile/me/deletion # 申请注销(NONE → PENDING;PENDING 幂等 200;COMPLETED → 409/9301)
GET /profile/me/deletion # 查询状态(无记录返回默认 NONE,不写库)
POST /profile/me/deletion/cancel # 取消注销(仅 PENDING 且未过期;PENDING → NONE)
```
- 身份仅从 JWT 获取,不接受 `userId` / `candidateId` 等参数;未登录 → `401`。
- **状态机**:NONE → PENDING → COMPLETED(终态),可在冷静期内取消回 NONE。
- **冷静期**:默认 30 天(`app.deletion.cooling-days` / `DELETION_COOLING_DAYS` 可配置)。
- **PENDING 写限制**:冷静期内 POST/PUT/PATCH/DELETE 业务写操作返回 `409 / 9302`;GET、OPTIONS 及三个注销接口放行。
- **COMPLETED 旧 JWT**:即使 JWT 未过期也拒绝普通业务请求(`409 / 9303`);登录时亦被阻止。
- **脱敏**:仅脱敏 `users` 表 PII(phone→`deleted_{id}`、email/passwordHash/avatarUrl 置空、nickname→`deleted`、is_active=false),**不删除任何业务数据**。
- **原手机号禁止重新注册**:注销完成后仅保存原手机号的 HMAC-SHA256 摘要(`deleted_phone_hash`,密钥 `app.deletion.phone-hash-secret`),不存明文;原手机号再次登录/注册返回 `409 / 9303`,且不会自动创建新账号。
- **懒转换**:到期 PENDING 首次 `GET /profile/me/deletion` 即返回 `COMPLETED`(独立事务重读最新记录),不会出现先 PENDING 后 COMPLETED 的不一致。
- **ADMIN**:最后一个有效 ADMIN 不允许注销 → `409 / 9306`。
- 错误码 `9301-9306`(详见 [docs/account-deletion-api.md](account-deletion-api.md))。
前端建议:发起注销与取消前先 `GET /profile/me/deletion` 确认当前状态;处于 PENDING 时应禁用业务提交类按钮并提示冷静期到期时间。
## 16. 个人数据导出(Personal Data Export)联调
**这不是简历导出**(简历导出走 `/resume-export-tasks`,两者独立)。个人数据导出把当前登录用户的全量业务数据异步打包成 ZIP 供本人下载,适用于 STUDENT / COMPANY / ADMIN:
```text
POST /profile/me/data-export-tasks # 创建导出任务(异步,返回 PENDING)
GET /profile/me/data-export-tasks?page=0&size=20 # 本人任务列表(倒序分页)
GET /profile/me/data-export-tasks/{taskId} # 本人任务详情(状态/大小等)
GET /profile/me/data-export-tasks/{taskId}/download # 下载已完成 ZIP(需 JWT)
```
- **异步**:创建后立即返回 `PENDING`;后台 Worker 打包完成后变为 `COMPLETED` 才可下载。
- **下载需 JWT**:下载地址不能脱离 JWT 单独访问;不提供公开 URL。
- **文件有效期**:默认 24 小时(`app.data-export.file-ttl-hours`),过期状态为 `EXPIRED` 且不可再下载。
- **导出范围/脱敏**:仅包含本人数据;不含他人手机号/邮箱/简历地址、不含 `passwordHash`/JWT/短信验证码/`deletedPhoneHash`、管理员内部审核字段、系统异常堆栈、服务器绝对路径与连接串。会话对方仅保留 `participantId`。详见 [docs/personal-data-export-api.md](personal-data-export-api.md)。
- **错误**:未登录 `401`;访问他人任务 `403/2002`;任务不存在 `404/9401`;存在未完成任务 `409/9402`;文件未就绪 `409/9403`;已过期 `409/9404`;失败 `409/9405`;文件缺失 `409/9406`。
- **注销限制**:注销 PENDING 禁止创建(`409/9302`);注销 COMPLETED 所有导出接口返回 `409/9303`。
前端建议:创建后轮询任务详情直到 `status=COMPLETED` 且 `downloadAvailable=true` 再展示下载按钮;处于注销 PENDING/COMPLETED 时应隐藏「导出我的数据」入口。
## 17. 结构化面试评分(Structured Interview Score)联调
**这不是 candidate_feedback**(候选人反馈是另一模块,两者独立;本模块是结构化维度评分)。企业为已完成(`COMPLETED`)的面试填写并提交评分,学生仅能在提交后查看自己的评分与历史:
```text
PUT /interviews/{interviewId}/score # 企业 owner 保存草稿(幂等更新,DRAFT)
GET /interviews/{interviewId}/score # 查看评分(企业 see DRAFT/SUBMITTED;学生仅 SUBMITTED)
POST /interviews/{interviewId}/score/submit # 提交草稿 → SUBMITTED(不可逆)
GET /interviews/{interviewId}/score/history # 评分快照历史(无记录返回 200 空数组)
```
- **五个维度**:`technicalScore`、`communicationScore`、`problemSolvingScore`、`roleFitScore`、`professionalismScore`,每项 1-5。
- **草稿**:PUT 时五维可留空;重复 PUT 更新同一行(`version` 递增)。
- **提交**:五维全部非空,否则 `400/1001`;`overallScore` 由服务端按 `(t+c+ps+rf+p)/5` 计算(两位小数 HALF_UP),**客户端不要传**,传了也会被忽略。
- **recommendation**(可选):`STRONG_HIRE` / `HIRE` / `NEUTRAL` / `NO_HIRE`,企业手动选择,不由 overallScore 推导。
- **权限**:写入仅岗位所属企业 owner(COMPANY);ADMIN 不可访问;STUDENT 不可写。学生只能看自己的 `SUBMITTED`,查看他人或 DRAFT 返回 `403/8402`。
- **状态机**:无评分 → `DRAFT` → `SUBMITTED`。`SUBMITTED` 后再次 PUT `409/9503`;重复 submit 幂等返回原评分(不新增历史、不重复通知)。
- **通知**:提交成功仅通知目标学生,`businessType=INTERVIEW`,`businessId=interviewId`;草稿与重复提交不发通知;企业自身不通知;无 Push。
- **错误码**:`9501` 并发冲突(409)、`9502` 面试未完成(409)、`9503` 已提交(409)、`9504` 无评分(404);归属非法 `8402`、未被授权 `2002`、面试不存在 `8401`。
- **脱敏**:响应不返回 `companyUserId`/`studentUserId`/`evaluatorUserId`/手机号/邮箱。
- **历史脱敏**:`GET /score/history` 不返回 `actorUserId`,仅 `actorRole`。
- 面试本身必须为 `COMPLETED` 才能评分(否则 `409/9502`)。
前端建议:企业端保存草稿用 PUT;「提交」用 POST submit 并在按钮上二次确认(不可逆);学生端仅在评分状态为 `SUBMITTED` 时展示。API 详见 [docs/interview-score-api.md](interview-score-api.md)。
## 18. 同岗位竞争力排名(Competition Ranking)联调
学生查看自己在某个 **已投递且为 PUBLISHED** 岗位候选人池中的竞争力排名(匿名):
```text
GET /jobs/{jobId}/competition-ranking # 仅 STUDENT 且已投递该岗位
```
- **权限矩阵**:
- 未登录 → `401`(安全过滤器)
- `COMPANY` / `ADMIN` → `403/2002`
- STUDENT 但未投递该岗位 → `403/2002`
- **STUDENT 唯一投递为 `WITHDRAWN` 或 `REJECTED` → `403/2002`**:投递已退出候选人池,与 cohort 排除口径一致,不能再查看排名。
- `jobId <= 0` → `400/1001`
- 岗位不存在或状态非 `PUBLISHED`(如 `DRAFT`/`CLOSED`)→ `404/7001`
- **cohort**:该岗位非 `WITHDRAWN`、非 `REJECTED` 的所有投递;`SUBMITTED/VIEWED/INTERVIEW/HIRED` 计入;同一学生同一岗位由数据库唯一约束保证仅一条。无有效评分的候选人**仍计入 `cohortSize`**,但不参与百分位,排在末尾。
- **综合分**:复用 `CandidateScoringCalculator` 真实公式(仅统计 `OK/ESTIMATED` 维度,`PENDING/INSUFFICIENT_DATA` 不折算为 0),取每人 `period <= 当前月` 的**最新**快照。
- **百分位**:严格复用企业评分看板公式 `lower / (scored - 1) * 100`;有分候选人数 `< 2` 时 `percentile = null`;未取整为 2 位小数 HALF_UP。
- **本人无有效评分**:`rank`、`overallScore`、`percentile` 均为 `null`(仍返回 `cohortSize`)。
- **响应结构(示例)**:
```json
{
"jobId": 9, "rank": 2, "cohortSize": 8, "overallScore": 77.50,
"percentile": 66.67, "status": "PARTIAL",
"asOfPeriod": "2026-08", "mySnapshotPeriod": "2026-08",
"dimensions": [ { "dimension": "EDUCATION", "score": 80, "status": "OK", "weight": 12.50 } ],
"gaps": [],
"topCandidates": [ { "rank": 1, "overallScore": 88.50 } ],
"rulesVersion": "candidate-scoring-v0.1", "generatedAt": "2026-08-26T10:00:00Z"
}
```
- **脱敏**:`topCandidates` 固定返回 Top 5、每项**仅含 `rank` 与 `overallScore`**,不含 `studentUserId`/`candidateId`/`name`/`school`/`phone`/`email`/`resumeUrl`/`applicationId` 等任何身份字段;响应整体也不含 `candidateId`。不提供 `top` 查询参数。
- **只读**:该接口为 `GET`,不写任何业务表;身份仅取自 JWT,不接收 `userId/studentUserId/candidateId` 参数。
- 规则版本固定 `rulesVersion = "candidate-scoring-v0.1"`;`asOfPeriod` 为计算截止月(当前月),`mySnapshotPeriod` 为当前学生实际使用的快照月(若与他人周期不同,二者可不同)。
- API 详见 [docs/competition-ranking-api.md](competition-ranking-api.md)。
## 19. 举报与屏蔽(Report & Block)联调
提供举报、屏蔽与 ADMIN 举报处理能力。**身份一律取 JWT,不接受 `userId/reporterId/reportedUserId` 参数**。
**举报(用户端)**
```text
POST /reports # 提交举报
GET /reports/me # 我的举报列表(status/page/size)
GET /reports/{id} # 举报详情(本人或 ADMIN)
```
- `targetType=USER` 必填 `targetUserId`;`targetType=MESSAGE` 必填 `messageId`。`reportedUserId` 由服务端推导,不传。
- `category` ≤40 字符;`reason` 1–1000 字符;`evidenceUrls` 可选(≤5 个,仅 http/https,每个 ≤500 字符);`idempotencyKey` 可选(≤64 字符)。
- **幂等**:同 `idempotencyKey` + 相同请求体重放返回原举报 `200`;同 key 不同请求体 → `409/9606`;同目标同分类已有 PENDING → `409/9603`。
- **限流**:每用户每小时最多 10 次 `POST /reports`,超限 → `429/9609`。
- **MESSAGE 举报**:无权访问该消息 → `403/2002`(不泄露消息是否存在);发送者由服务端推导,禁止客户端注入。
- 举报自己 → `400/9602`;不存在目标 → 复用用户不存在错误码。
**屏蔽(用户端)**
```text
PUT /blocks/{blockedUserId} # 屏蔽
DELETE /blocks/{blockedUserId} # 解除(幂等)
GET /blocks/me # 我的屏蔽列表(page/size)
```
- 屏蔽自己 → `400/9608`;PUT 已存在返回 200 不新增;DELETE 不存在返回 200 不报错。
- 屏蔽仅影响**站内信**:禁止与对方新建 DIRECT 会话、禁止向对方发送新消息(`403/2002`,模糊提示“message cannot be sent”);历史消息仍可读,且不影响系统通知、投递、Offer、面试、评分、评价。
**ADMIN 举报管理**
```text
GET /admin/reports # 举报列表(status/targetType/category/page/size)
GET /admin/reports/{id} # 举报详情
POST /admin/reports/{id}/decision # 处理决定
```
- 仅 ADMIN;COMPANY/STUDENT/未登录分别 `403/403/401`。
- `decision` 仅 `RESOLVED`/`REJECTED`;REJECTED 必填 `note`;重复决定 → `409/9604`;非法流转 → `409/9605`。
- 处理不自动封禁、不删除消息、不修改投递/面试/评分状态;处理后向举报人发送一条 `REPORT_DECISION` 通知。
**脱敏**:所有举报/屏蔽响应均**不返回** phone、email、passwordHash、resumeUrl、JWT、消息全文;MESSAGE 举报仅返回脱敏 `messagePreview`;举报列表不返回举报人内部 ID。
- API 详见 [docs/report-block-api.md](report-block-api.md)。

211
docs/interview-score-api.md

@ -0,0 +1,211 @@
# 结构化面试评分 API(V31)
本文档描述「结构化面试评分」模块。**这与候选人反馈(`candidate_feedback`)是两套完全独立的机制**,请勿混淆:
- candidate_feedback / Candidate Scoring Engine:既有的候选人反馈与评分治理模块,语义保持不变。
- 结构化面试评分:企业针对某一场**已完成(`COMPLETED`)**的面试,按固定五个维度填写并提交评分。独立落库到 `interview_scores` / `interview_score_history` 两张新表,不写 candidate_feedback。
只支持单面试官评分模型(一场面试一条评分);不新增多面试官模型。
OpenAPI 路径总数 113 → **116**(3 个 path、4 个 operation)。
---
## 一、接口总览
| 操作 | 路径 | 说明 |
| --- | --- | --- |
| PUT | `/interviews/{interviewId}/score` | 企业 owner 保存评分草稿(幂等更新) |
| GET | `/interviews/{interviewId}/score` | 查看评分(企业见 DRAFT/SUBMITTED;学生仅 SUBMITTED) |
| POST | `/interviews/{interviewId}/score/submit` | 提交草稿 → SUBMITTED(不可逆) |
| GET | `/interviews/{interviewId}/score/history` | 评分快照历史(无记录返回 200 空数组) |
### 身份与权限
- 所有接口都需要登录(JWT);身份只从 JWT/SecurityContext 获取,**不接受 `userId`/`studentUserId`/`companyId` 参数**。
- 归属校验:`companyRepository.findByOwnerUserId(userId)` 解析企业;`interview.companyUserId == company.ownerUserId` 且经由 application→job 的 `job.companyId == company.id` 且 `application.studentUserId == interview.studentUserId`,否则 `403/8402`。
- 企业 owner(COMPANY):可写可读草稿与已提交。
- 目标学生(STUDENT):只能查看**已提交(SUBMITTED)**的评分;查看 DRAFT 或他人评分返回 `403/8402`。
- ADMIN:不允许访问(`403/8402`)。
- 面试不存在 `404/8401`。
---
## 二、保存评分草稿
`PUT /interviews/{interviewId}/score`
请求体:
```json
{
"technicalScore": null,
"communicationScore": 4,
"problemSolvingScore": 3,
"roleFitScore": null,
"professionalismScore": 5,
"recommendation": "HIRE",
"comment": "综合素质较好"
}
```
字段:
- 五个维度 `technicalScore` / `communicationScore` / `problemSolvingScore` / `roleFitScore` / `professionalismScore`,取值 1-5;草稿阶段可留空(`null`)。
- `recommendation`(可选):`STRONG_HIRE` / `HIRE` / `NEUTRAL` / `NO_HIRE`,企业手动选择,不由 overallScore 推导;非法值 `400/1001`。
- `comment`(可选):≤2000 字符。
- `overallScore`:**DTO 中不提供该字段**,由服务端计算并忽略客户端传入值。
约束:
- 无评分时创建 `DRAFT`;已有 DRAFT 时更新同一行(`@Version` 递增)。
- `SUBMITTED` 后再次 PUT → `409/9503`。
- 保存草稿不写历史节点(历史仅写 `NULL→DRAFT`、`DRAFT→SUBMITTED` 两次),但写审计日志 `INTERVIEW_SCORE_SAVED`。
- 并发冲突 → `409/9501`。
成功响应(脱敏):
```json
{
"code": 200,
"message": "ok",
"data": {
"id": 1,
"interviewId": 10,
"applicationId": 20,
"status": "DRAFT",
"technicalScore": null,
"communicationScore": 4,
"problemSolvingScore": 3,
"roleFitScore": null,
"professionalismScore": 5,
"overallScore": null,
"recommendation": "HIRE",
"comment": "综合素质较好",
"submittedAt": null,
"version": 1,
"createdAt": "2026-08-26T10:00:00",
"updatedAt": "2026-08-26T10:00:00"
}
}
```
> 脱敏:响应不返回 `companyUserId` / `studentUserId` / `evaluatorUserId` / 手机号 / 邮箱。
---
## 三、查看评分
`GET /interviews/{interviewId}/score`
- 企业 owner:可看 `DRAFT` 与 `SUBMITTED`。
- 目标学生:仅可看 `SUBMITTED`;查看 DRAFT → `403/8402`。
- 无评分 → `404/9504`。
- 面试未完成 → `409/9502`。
---
## 四、提交评分
`POST /interviews/{interviewId}/score/submit`
无请求体。将 `DRAFT` 提交为 `SUBMITTED`:
- 五个维度必须全部非空,否则 `400/1001`。
- `overallScore` 由服务端计算:`(t + c + ps + rf + p) / 5`,`BigDecimal`,保留两位小数,`RoundingMode.HALF_UP`。
- 提交成功后 `submittedAt` 落库;不可逆。
- 写历史节点 `DRAFT→SUBMITTED`;写审计日志 `INTERVIEW_SCORE_SUBMITTED`。
- **幂等**:重复 submit 已 SUBMITTED → `200` 返回原评分;不新增历史、不重复通知。
- **通知**:仅通知目标学生一次,`businessType=INTERVIEW`、`businessId=interviewId`、幂等键 `interview-score-submitted-{interviewId}`;草稿不发通知;不通知企业自身;无 Push。
- 并发冲突 → `409/9501`。
---
## 五、查看评分历史
`GET /interviews/{interviewId}/score/history`
- 企业 owner:可查看全部历史。
- 目标学生:仅在评分 `SUBMITTED` 后可查看。
- 无历史记录 → `200` 空数组 `items=[]`。
- 历史快照 `snapshot` 为解析后的维度分 + `overallScore`(JSON 字符串落库于 `snapshot_text` TEXT 字段,不返回内部用户标识)。
响应示例:
```json
{
"code": 200,
"message": "ok",
"data": [
{
"id": 1,
"scoreId": 1,
"interviewId": 10,
"fromStatus": null,
"toStatus": "DRAFT",
"actorRole": "COMPANY",
"snapshot": { "technicalScore": null, "overallScore": null },
"createdAt": "2026-08-26T10:00:00"
},
{
"id": 2,
"scoreId": 1,
"interviewId": 10,
"fromStatus": "DRAFT",
"toStatus": "SUBMITTED",
"actorRole": "COMPANY",
"snapshot": { "technicalScore": 4, "overallScore": 4.20 },
"createdAt": "2026-08-26T10:05:00"
}
]
}
```
> 脱敏:历史条目不返回 `actorUserId`、手机号、邮箱。
---
## 六、状态机与历史
```
无评分 → DRAFT → SUBMITTED
```
- PUT 无评分 → 创建 `DRAFT`(写历史 `NULL→DRAFT`)。
- PUT 已有 DRAFT → 更新同一行,不写历史,写审计 `INTERVIEW_SCORE_SAVED`。
- POST submit:DRAFT → SUBMITTED(写历史 `DRAFT→SUBMITTED`,写审计 `INTERVIEW_SCORE_SUBMITTED`)。
- `SUBMITTED` 不可逆;重复 submit 幂等。
- 草稿每次 PUT `version` 递增;`@Version` 乐观锁防并发。
---
## 七、错误码
| 错误码 | 含义 | HTTP |
| --- | --- | --- |
| 9501 | 并发冲突(更新或首次并发创建撞唯一约束) | 409 |
| 9502 | 面试未完成(非 COMPLETED),不可评分 | 409 |
| 9503 | 评分已提交,不可再修改 | 409 |
| 9504 | 评分不存在 | 404 |
| 8401 | 面试不存在 | 404 |
| 8402 | 归属校验失败 / 越权访问 | 403 |
| 2002 | 非 COMPANY(对写入),未授权访问 | 403 |
| 1001 | 参数校验失败(非 1-5 维度、非法推荐值、提交缺维度) | 400 |
---
## 八、数据库(V31)
新增两张表,均 `ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`:
- `interview_scores`:`interview_id` 唯一;五个评分字段允许 NULL 且在 1-5;`overall_score` 1.00-5.00;`status` CHECK(DRAFT/SUBMITTED);`recommendation` CHECK;`SUBMITTED` 时五维、`overallScore`、`submittedAt` 均非空;`@Version`;外键 `ON DELETE CASCADE`。
- `interview_score_history`:`snapshot_text TEXT`(不使用 JSON 类型,避免 MySQL/H2 兼容问题);外键 `ON DELETE CASCADE`。
不修改 V1-V30、不修改 CandidateFeedback、不修改 Interview 状态机、不修改 Candidate Scoring Engine、不修改个人数据导出/账号注销/隐私设置。
---
## 九、文档与验证
- OpenAPI 路径数 113 → 116;`docs/openapi-rc.json` 已同步。
- 验证:`python docs/verify_openapi.py`;项目 `mvn test`、`mvn clean verify`。

266
docs/offer-advance-api.md

@ -0,0 +1,266 @@
# Offer 进阶模块接口说明
> 职来 (mydreamjob) · Offer 进阶能力
> 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>`
> 在 [Offer 管理基础模块](offer-api.md) 之上增量开发,未修改任何已验收接口的公共签名。
## 1. 概述
本模块在基础 Offer(创建/发送/接受/拒绝/洽谈/到期/提醒/时间线)之上,为招聘双方补充
**薪资洽谈(还价)**、**Offer 对比** 与 **企业 Offer 统计** 三组能力:
| 能力 | 角色 | 说明 |
|---|---|---|
| 还价(Counter-Offer) | 候选人发起 · 企业接受/拒绝 | 候选人可对 `SENT/NEGOTIATING` 的 Offer 提出新薪资/入职日期;企业接受后更新 Offer 条款并成交 |
| Offer 对比(Compare) | 候选人本人 | 一次性横向比较本人 2-5 个非 DRAFT 的 Offer |
| Offer 统计(Statistics) | 企业 | 企业 Offer 状态分布、接受率、平均响应时间看板 |
同时将 **WITHDRAWN(撤销)** 纳入 Offer 状态机:`SENT/NEGOTIATING` 可由企业撤销,
撤销/过期时自动将 PENDING 还价置为 `EXPIRED` 并释放唯一还价槽位。
## 2. 状态机增量:WITHDRAWN(撤销)
基础状态:`DRAFT / SENT / NEGOTIATING / ACCEPTED / REJECTED / EXPIRED`。
本模块新增 **`WITHDRAWN`(终态,仅企业可触发,通过通用状态接口)**:
```text
SENT ──────────(企业 WITHDRAW)──> WITHDRAWN
NEGOTIATING ───(企业 WITHDRAW)──> WITHDRAWN
```
撤销规则:
- **可撤销**:仅 `SENT` / `NEGOTIATING`(`PATCH /offers/{id}/status` 传 `{"status":"WITHDRAWN"}`,仅企业角色);
- **非法撤销**:`DRAFT / ACCEPTED / REJECTED / EXPIRED` → 409 `INVALID_OFFER_STATUS_TRANSITION`(走状态机校验);
- **副效应**:撤销/过期时,该 Offer 所有 PENDING 还价原子置为 `EXPIRED`、`active_key` 置 `NULL` 释放槽位;Offer 自身 `active_key` 置 `NULL` 释放"唯一有效 Offer"槽位;
- **历史**:每次撤销写入 `offer_status_history`(from=`SENT/NEGOTIATING`, to=`WITHDRAWN`, operatorRole=`COMPANY`);
- **通知**:复用 `NotificationService.publishEvent`,事件类型 `OFFER_WITHDRAWN`,幂等键 `offer-withdrawn-{offerId}`(沿用现有通知机制,未新建链路)。
## 3. 数据库迁移(V26)
迁移文件:`src/main/resources/db/migration/V26__offer_advance.sql`(兼容 MySQL 8 / H2 MODE=MySQL,未改动 V1-V25)。
**`offers` 表新增字段**(统计与撤销沿用现有字段,无需新增,此处为校验/约束确认):
- 无结构破坏:`status`、`active_key`、`expire_at` 等继续复用。
**新增 `offer_counter_offers` 表**(候选人还价记录):
| 字段 | 说明 |
|---|---|
| `id` | 主键自增 |
| `offer_id` | 所属 Offer(FK → offers.id) |
| `student_id` | 发起还价的候选人(FK → users.id) |
| `proposed_salary_min/max` | 提议薪资区间 |
| `proposed_onboarding_date` | 提议入职日期 |
| `reason` | 说明,≤ 2000 字符 |
| `status` | `PENDING / ACCEPTED / REJECTED / EXPIRED`,默认 `PENDING` |
| `active_key` | 可空判别列:`PENDING` 时为 `ACTIVE`;`ACCEPTED/REJECTED/EXPIRED` 时为 `NULL` |
| `version` | 乐观锁 |
| `created_at / updated_at` | 审计时间 |
- **唯一约束** `uk_co_active UNIQUE(offer_id, active_key)`:保证同一 Offer 同时最多一个 PENDING 还价;
- `active_key` 释放即"槽位释放",释放后可再次发起新还价。
## 4. 接口清单(6 个 operation / 5 个 path)
### 4.1 发起还价
```
POST /offers/{id}/counter-offers
```
权限:**候选人本人**(`offer.candidateId == 当前用户`)。
请求体:
| 字段 | 必填 | 约束 |
|---|---|---|
| `proposedSalaryMin` | 否 | ≥ 0 |
| `proposedSalaryMax` | 否 | ≥ 0 |
| `proposedOnboardingDate` | 否 | 必须是未来日期 |
| `reason` | 否 | ≤ 2000 字符 |
```json
{
"proposedSalaryMin": 18000,
"proposedSalaryMax": 25000,
"proposedOnboardingDate": "2026-10-01",
"reason": "参考行业水平与个人经验,期望更高薪资"
}
```
业务规则:
- 仅 `SENT` / `NEGOTIATING` 可发起(否则 400 `OFFER_NOT_NEGOTIABLE`);
- 同一 Offer 已有 PENDING 还价 → 409 `OFFER_COUNTER_OFFER_EXISTS`;
- Offer 已过期 → 409 `OFFER_EXPIRED`;
- 发起时若 Offer 为 `SENT`,**自动流转**为 `NEGOTIATING`(写历史 + `OFFER_NEGOTIATING` 通知);
- 其他账号 → 403(`FORBIDDEN`);Offer 不存在 → 404(`OFFER_NOT_FOUND`)。
响应(HTTP 200):
```json
{ "code": 200, "message": "ok",
"data": { "id": 11, "offerId": 1, "studentId": 100, "proposedSalaryMin": 18000,
"proposedSalaryMax": 25000, "proposedOnboardingDate": "2026-10-01", "reason": "...",
"status": "PENDING", "version": 0, "createdAt": "...", "updatedAt": "..." },
"requestId": "...", "timestamp": "..." }
```
### 4.2 还价列表
```
GET /offers/{id}/counter-offers
```
权限:候选人本人(其 Offer)或该 Offer 所属企业。
- 按 `createdAt` 升序返回全部还价记录;
- 其他账号 → 403;Offer 不存在 → 404。
响应(HTTP 200):
```json
{ "code": 200, "data": [ { "id": 11, "offerId": 1, "status": "PENDING", "proposedSalaryMin": 18000, ... } ], ... }
```
### 4.3 接受还价(企业)
```
PATCH /offers/{id}/counter-offers/{coId}/accept
```
权限:**该 Offer 所属企业**(`company.id == offer.companyId`)。
业务规则:
- 还价不存在或不属于该 Offer → 404 `OFFER_COUNTER_OFFER_NOT_FOUND`(8110);
- 还价非 PENDING → 409 `OFFER_COUNTER_OFFER_NOT_PENDING`(8111);
- Offer 已撤销/过期 → 将还价置为 EXPIRED 并返回 409 `OFFER_COUNTER_OFFER_EXPIRED`(8113);
- 接受后:以还价条款更新 Offer 的 `salaryMin / salaryMax / onboardingDate`,将 Offer 流转为 `ACCEPTED`,还价置 `ACCEPTED` 并释放 `active_key`;
- 并发保护:乐观锁(`version`),冲突时重读,已在目标状态幂等成功。
响应(HTTP 200):
```json
{ "code": 200, "data": { "id": 11, "offerId": 1, "status": "ACCEPTED", ... }, ... }
```
### 4.4 拒绝还价(企业)
```
PATCH /offers/{id}/counter-offers/{coId}/reject
```
权限:**该 Offer 所属企业**。
业务规则:
- 还价不存在/不属于该 Offer → 404 `OFFER_COUNTER_OFFER_NOT_FOUND`(8110);
- 还价已拒绝 → 幂等返回(200);
- 还价非 PENDING 且非 REJECTED → 409 `OFFER_COUNTER_OFFER_NOT_PENDING`(8111);
- Offer 已撤销/过期 → 置 EXPIRED 并返回 409 `OFFER_COUNTER_OFFER_EXPIRED`(8113);
- 拒绝后:还价置 `REJECTED`、释放 `active_key`,Offer 保持 `NEGOTIATING`。
响应(HTTP 200):
```json
{ "code": 200, "data": { "id": 11, "offerId": 1, "status": "REJECTED", ... }, ... }
```
### 4.5 Offer 对比(候选人)
```
GET /offers/compare?ids=1,2,3
```
权限:**候选人本人**;比较的 Offer 必须全部属于当前用户。
查询参数:
- `ids`(必填):逗号分隔的 Offer id,支持 **2-5 个**。
参数校验(**非法一律返回 400 / 1001,绝不返回 500**):
- `ids` 缺失/空白、含空值(`1,,2`)、含非数字(`abc`)、含非正数(`1,-2`)→ 400;
- `ids` 个数 < 2 或 > 5 → 400 `OFFER_COMPARE_TOO_MANY`(8115);
- `ids` 存在重复 → 400 `BAD_REQUEST`。
业务规则:
- 仅比较**非 DRAFT** 的 Offer(`DRAFT` 不计入);
- 任一 Offer 不属于当前用户或未命中(如 DRAFT)→ 403 `OFFER_COMPARE_NOT_OWNER`(8116);
- 返回 Offer 视图并附带 `companyName`。
响应(HTTP 200):
```json
{ "code": 200, "data": [
{ "id": 1, "companyName": "示例科技", "title": "后端工程师", "status": "SENT",
"salaryMin": 20000, "salaryMax": 30000, "onboardingDate": "2026-09-01", ... },
{ "id": 2, "companyName": "示例科技", "status": "NEGOTIATING", ... }
], "requestId": "...", "timestamp": "..." }
```
### 4.6 Offer 统计(企业)
```
GET /offers/statistics?months=6
```
权限:**企业账号**(当前用户绑定企业)。
查询参数:
- `months`(可选,默认 `6`):统计回溯月数,1-24;越界按默认 6 处理。
返回:
- `totalOffers`:期内 Offer 总数;
- `byStatus`:各状态计数(含 DRAFT/SENT/NEGOTIATING/ACCEPTED/REJECTED/EXPIRED/WITHDRAWN,缺失补 0);
- `acceptanceRate`:接受率 = `ACCEPTED / (ACCEPTED + REJECTED)`,无数据为 `null`;
- `avgResponseHours`:`SENT→ACCEPTED/REJECTED` 平均响应小时(按状态历史计算),无数据为 `null`;
- `periodMonths`:实际使用的月数。
响应(HTTP 200):
```json
{ "code": 200,
"data": { "totalOffers": 10, "periodMonths": 6,
"byStatus": { "DRAFT": 2, "SENT": 3, "NEGOTIATING": 0,
"ACCEPTED": 4, "REJECTED": 1, "EXPIRED": 0, "WITHDRAWN": 0 },
"acceptanceRate": 0.8, "avgResponseHours": 26.5 },
"requestId": "...", "timestamp": "..." }
```
## 5. 权限矩阵
| 接口 | 候选人本人 | 岗位所属企业 | 其他账号 | 系统 |
|---|---|---|---|---|
| 发起还价 POST counter-offers | ✅ | 403 | 403 | — |
| 还价列表 GET counter-offers | ✅(仅本人的) | ✅(本企业) | 403 | — |
| 接受还价 accept | 403 | ✅ | 403 | — |
| 拒绝还价 reject | 403 | ✅ | 403 | — |
| Offer 对比 compare | ✅(全部属于本人) | 403 | 403 | — |
| Offer 统计 statistics | 403 | ✅ | 403 | — |
| 撤销 WITHDRAWN | 403 | ✅(`SENT/NEGOTIATING`) | 403 | — |
所有身份一律来自 JWT,绝不信任请求参数中的 `companyId/candidateId`。
## 6. 错误码(本模块相关)
| 错误码 | 枚举 | HTTP | 含义 |
|---|---|---|---|
| 8109 | `OFFER_COUNTER_OFFER_EXISTS` | 409 | 该 Offer 已有 PENDING 还价 |
| 8110 | `OFFER_COUNTER_OFFER_NOT_FOUND` | 404 | 还价不存在/不属于该 Offer |
| 8111 | `OFFER_COUNTER_OFFER_NOT_PENDING` | 409 | 还价非 PENDING,无法接受/拒绝 |
| 8112 | `OFFER_NOT_WITHDRAWABLE` | 400 | Offer 当前状态不可撤销(预留,实际经状态机返回 8103/409) |
| 8113 | `OFFER_COUNTER_OFFER_EXPIRED` | 409 | Offer 已撤销/过期,还价失效 |
| 8114 | `OFFER_NOT_NEGOTIABLE` | 400 | Offer 状态不允许发起还价 |
| 8115 | `OFFER_COMPARE_TOO_MANY` | 400 | 对比数量需 2-5 个 |
| 8116 | `OFFER_COMPARE_NOT_OWNER` | 403 | 存在非本人/非有效 Offer |
复用基础错误码:`OFFER_NOT_FOUND`(8101/404)、`OFFER_EXPIRED`(8104/409)、`INVALID_OFFER_STATUS_TRANSITION`(8103/409)、`FORBIDDEN`(2002/403)、`BAD_REQUEST`(1001/400)。
## 7. OpenAPI
本模块新增 **6 个 operation / 5 个 path**(绝对路径数),OpenAPI 总路径数由 102 → **107**:
- `/offers/{id}/counter-offers`(POST + GET)
- `/offers/{id}/counter-offers/{coId}/accept`
- `/offers/{id}/counter-offers/{coId}/reject`
- `/offers/compare`
- `/offers/statistics`
> 注意:是 +5 个 path(其中 counter-offers 承载 2 个 operation),**不是 +6 个 path**。

9386
docs/openapi-rc.json

File diff suppressed because one or more lines are too long

204
docs/personal-data-export-api.md

@ -0,0 +1,204 @@
# 个人数据导出 API(V30)
本文档描述「个人数据导出」模块。**这与简历导出(`/resume-export-tasks`)是两套完全独立的机制**,请勿混淆:
- 简历导出:面向单个简历文件/快照的导出,走既有 `/resume-export-tasks` 接口,语义保持不变。
- 个人数据导出:把当前登录用户的全量业务数据打包成一个异步 ZIP 文件下载,走下面的新接口。
只实现当前登录用户「本人」数据的导出;任务创建是**异步**的:先返回一个 `PENDING` 任务,后台 Worker 收集并打包,完成后才能下载。
---
## 一、接口总览
| 操作 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/profile/me/data-export-tasks` | 创建导出任务(异步 ZIP) |
| GET | `/profile/me/data-export-tasks` | 查询本人任务列表(分页) |
| GET | `/profile/me/data-export-tasks/{taskId}` | 查询本人某个任务详情 |
| GET | `/profile/me/data-export-tasks/{taskId}/download` | 下载已完成 ZIP |
新增 3 个 path、4 个 operation。OpenAPI 路径总数 110 → **113**(详情接口已覆盖状态查询,未单独新增 `/status`)。
### 身份与权限
- 所有接口都需要登录(JWT);身份只从 JWT/SecurityContext 获取,**不接受 `userId`/`candidateId` 参数**。
- `STUDENT`、`COMPANY`、`ADMIN` 均可导出**自己的**任务。
- 只能查询/下载自己的任务;访问他人任务返回 `403/2002`,任务不存在返回 `404/9401`。
---
## 二、创建任务
`POST /profile/me/data-export-tasks`
请求体(均可选):
```json
{ "format": "ZIP", "idempotencyKey": "optional-client-key" }
```
约束:
- `format` 当前仅允许 `ZIP`(非法值返回 `400/1001`)。
- `idempotencyKey` 可选,最大 64 字符;用于创建幂等重放。
- 同一用户存在未完成任务(`PENDING`/`PROCESSING`)时:
- 相同 `idempotencyKey` 重放 → 返回原任务;
- 不同 key → 返回 `409/9402`。
- 已完成且未过期的旧任务 → 直接返回该任务;若旧任务已过期则可创建新任务。
- 账号注销 `PENDING`/`COMPLETED` 时禁止创建(见第八节)。
成功响应:
```json
{
"code": 200,
"message": "ok",
"data": {
"taskId": 1,
"status": "PENDING",
"format": "ZIP",
"requestedAt": "2026-08-25T10:00:00",
"startedAt": null,
"completedAt": null,
"expiresAt": null,
"sizeBytes": null,
"downloadAvailable": false
}
}
```
> 响应**绝不**包含 `storageKey`、服务器绝对路径、内部异常堆栈等字段。
---
## 三、查询任务列表 / 详情
`GET /profile/me/data-export-tasks?page=0&size=20&status=`
- `page` 默认 0,不得小于 0。
- `size` 默认 20,范围 1–100(越界返回 `400/1001`)。
- `status` 可选:`PENDING` / `PROCESSING` / `COMPLETED` / `FAILED` / `EXPIRED` / `CANCELLED`。
- 按 `createdAt` **倒序**,返回标准分页结构。
`GET /profile/me/data-export-tasks/{taskId}`
- 任务不存在 → `404/9401`。
- 任务属于他人 → `403/2002`。
- 响应不返回 `storageKey`、绝对路径、内部字段。
---
## 四、下载
`GET /profile/me/data-export-tasks/{taskId}/download`
- 仅任务本人可下载。
- 仅 `COMPLETED` **且未过期**可下载。
- `PENDING`/`PROCESSING` → `409/9403`(文件尚未准备好了)。
- `FAILED` → `409/9405`。
- `EXPIRED`/`CANCELLED` → `409/9404`。
- 文件不存在 → `409/9406`(不会返回 500)。
- 返回 `Content-Disposition: attachment`,`Content-Type: application/octet-stream`(内容为 ZIP)。
- **下载凭证不可脱离 JWT**;不返回任何公开 URL。
- 下载成功会记录 `downloadedAt` 并写审计事件 `DATA_EXPORT_DOWNLOADED`(留有接口;服务记录 downloadedAt)。
---
## 五、任务状态机
```
PENDING ─→ PROCESSING ─→ COMPLETED
PENDING ─→ PROCESSING ─→ FAILED
PENDING ─→ FAILED
PROCESSING ─→ FAILED
COMPLETED ─→ EXPIRED
COMPLETED/CANCELLED(账号注销清理)
```
- 创建后默认 `PENDING`;后台 Worker 用 `@Version` 乐观锁抢占(只有一个 Worker 能把 `PENDING→PROCESSING`,并发后到者跳过)。
- 完成后写入 `completedAt`、`expiresAt`、`sizeBytes`、`checksumSha256`。
- 默认文件有效期 **24 小时**,过期由定时任务把 `COMPLETED→EXPIRED` 并删除本地导出文件;**不删除任何业务数据库数据**。
- 失败仅记录安全的 `errorCode/errorMessage`,不记录密码、Token、手机号等敏感内容。
---
## 六、导出数据范围
ZIP 内部结构(`personal-data/` 根目录):
```
personal-data/
manifest.json # schemaVersion/format/requestedAt/fileCount/sections
profile.json # 基本资料 + 学生资料
preferences.json # 求职偏好
resumes/<文件> # 用户自己上传的简历文件 + resumes.json 元数据
applications.json # 投递记录
application-timelines.json
interviews.json # 本人参与/发起的面试
interview-timelines.json
offers.json # 本人收到或创建(COMPANY)的 Offer
offer-timelines.json
feedback.json # 本人候选人反馈
favorites.json # 本人岗位收藏
notifications.json # 本人通知
conversations.json # 本人会话
messages.json # 本人会话消息
candidate-scoring.json # 本人评分快照/趋势
scoring-appeals.json # 本人评分申诉及历史
```
> 以上为当前实现实际包含的 section(`manifest.json` 的 `sections` 字段会列出全部 section 名称);`resumes/` 目录下同时附带用户自己上传的简历文件二进制。
### 允许导出(本人)
- 基本资料:`userId / nickname / avatarUrl / email`(如业务允许)/ 账号创建时间等;**不含 passwordHash**。
- 学生资料与求职偏好。
- 简历版本及用户自己上传的简历文件。
- `COMPANY` 用户自己创建的企业/岗位资料。
- 自己的投递记录与状态时间线、自己参与的面试、自己收到/创建的 Offer、自己的候选人反馈、岗位收藏、通知、会话与消息、评分快照/趋势/申诉及历史。
### 脱敏与禁止导出
- 禁止:`passwordHash`、JWT、短信验证码、JWT_SECRET、`deletedPhoneHash`、数据库密码、管理员内部审核备注、内部风控规则、系统异常堆栈、服务器绝对路径、RabbitMQ/Redis/MySQL 连接信息。
- 不得导出**其他用户**的手机号、邮箱、密码、简历地址。
- 会话消息:只导出当前用户参与的消息;对方**仅保留 `participantId`(脱敏显示名)**,不导出对方完整资料。
---
## 七、错误码
| 错误码 | 含义 | HTTP |
| --- | --- | --- |
| 1001 | 参数错误(非法 format、page/size 越界) | 400 |
| 2001 | 未认证 | 401 |
| 2002 | 无权限(访问他人任务) | 403 |
| 9401 | 任务不存在 | 404 |
| 9402 | 存在未完成任务 | 409 |
| 9403 | 文件尚未生成 | 409 |
| 9404 | 导出文件已过期 | 409 |
| 9405 | 导出失败 | 409 |
| 9406 | 导出文件不存在 | 409 |
| 9302 | 注销进行中禁止操作 | 409 |
| 9303 | 账号已注销 | 409 |
错误响应统一格式(`ApiResponse`):`{ "code": <码>, "message": "...", "requestId": "..." }`。
---
## 八、账号注销兼容
- **注销 PENDING 用户**:不允许创建新导出任务;已创建但未执行的任务会被 Worker 阻止并标记 `FAILED`(不再生成含个人信息的新文件)。
- **注销 COMPLETED 用户**:所有导出接口返回 `409/9303`(创建/查询/详情/下载均被拦截),不能看到或下载新任务。
- **注销完成清理**:账号注销完成(脱敏)流程会删除该用户的导出文件,将未完成任务标记为 `CANCELLED` 或 `EXPIRED`,写入审计事件;**不删除任何原业务数据库数据**。
---
## 九、审计
复用 `AuditService`,新增 action:
- `DATA_EXPORT_REQUESTED` / `DATA_EXPORT_PROCESSING` / `DATA_EXPORT_COMPLETED`
- `DATA_EXPORT_DOWNLOADED` / `DATA_EXPORT_FAILED` / `DATA_EXPORT_EXPIRED`
details 仅记录:`taskId / userId / format / status / sizeBytes` 等;禁止写入文件内容、`storageKey`、手机号、邮箱、密码、Token、绝对路径。

102
docs/privacy-settings-api.md

@ -0,0 +1,102 @@
# 隐私设置(Privacy Settings)API 联调说明
> 模块:学生隐私设置。用于控制**简历可见性**与**联系方式可见性**,仅 STUDENT 角色可用。
## 1. 接口概览
| 方法 | 路径 | 角色 | 说明 |
|------|------|------|------|
| GET | `/profile/me/privacy` | STUDENT | 查询当前学生隐私设置;无记录返回默认对象,不写库 |
| PUT | `/profile/me/privacy` | STUDENT | 更新当前学生隐私设置;upsert 确保同一学生至多一行 |
其余角色:COMPANY / ADMIN → `403 / 2002`;未登录 → `HTTP 401`。
身份一律从 JWT / SecurityContext 获取,**不接受** `userId`、`studentUserId`、`candidateId` 等请求参数。
## 2. 枚举 `ResumeVisibility`
| 值 | 含义 |
|----|------|
| `PRIVATE`(默认) | 企业不可见 |
| `APPLIED_COMPANIES` | 仅当学生在该企业存在**有效投递**时企业可见(WITHDRAWN / REJECTED 不算有效投递) |
| `VERIFIED_COMPANIES` | 仅 `verified=true` 的企业可见 |
## 3. GET /profile/me/privacy
无 Path / Query / Body 参数。
无历史记录时返回**默认对象**(不会向数据库写入任何记录):
```json
{
"code": 200,
"message": "ok",
"data": {
"studentUserId": 2,
"resumeVisibility": "PRIVATE",
"contactVisible": false,
"version": 0
},
"timestamp": "2026-08-25T10:00:00Z"
}
```
有历史记录时额外包含 `id`、`createdAt`、`updatedAt`。
| 状态码 | 业务码 | 说明 |
|--------|--------|------|
| 200 | 200 | 成功 |
| 401 | 401 | 未登录 |
| 403 | 2002 | 非 STUDENT 角色 |
## 4. PUT /profile/me/privacy
请求体(`ContactVisible`、`resumeVisibility` 必填,`version` 可选):
```json
{
"resumeVisibility": "APPLIED_COMPANIES",
"contactVisible": true,
"version": 0
}
```
- 首次调用创建记录,之后更新,始终保证同一学生只有一行。
- `version` 不传或传 `0` 表示非严格校验;传了且与当前版本不一致 → `409 / 9201`。
响应:
```json
{
"code": 200,
"message": "ok",
"data": {
"id": 1,
"studentUserId": 2,
"resumeVisibility": "APPLIED_COMPANIES",
"contactVisible": true,
"version": 0,
"createdAt": "2026-08-25T10:00:00",
"updatedAt": "2026-08-25T10:00:00"
},
"timestamp": "2026-08-25T10:00:00Z"
}
```
| 状态码 | 业务码 | 说明 |
|--------|--------|------|
| 200 | 200 | 成功 |
| 400 | 1001 | 缺少必填字段 / 非法枚举 |
| 401 | 401 | 未登录 |
| 403 | 2002 | 非 STUDENT 角色 |
| 409 | 9201 | 乐观锁版本冲突 |
## 5. 隐私脱敏接入说明
现有企业候选人详情 `/company/candidates/{applicationId}` 与候选人列表 `/company/candidates` 已在响应中排除 `resumeUrl` 与联系方式字段,保持原路径、原权限、原白名单不变,且本轮不新增联系方式字段。
隐私策略方法 `canCompanyViewResume(studentUserId, companyId, applicationId)` 已随本模块提供,供企业侧按 `ResumeVisibility` 判定简历可见性。
## 6. 审计
更新隐私设置会写入审计日志 `UPDATE_PRIVACY_SETTINGS`。审计中**不得**写入 `phone / email / password / jwt / resumeUrl` 等敏感值。

152
docs/report-block-api.md

@ -0,0 +1,152 @@
# 举报与屏蔽 API(V33)
> 版本:V33 实现版
> 状态:已编码实现,待黑盒验收
> 适用范围:举报、屏蔽、ADMIN 举报处理;仅允许对 `MessagingService` 增加屏蔽校验。
> OpenAPI 117 → 125 paths(新增 8 path / 9 operation)。
## 1. 定位与边界
| 接口 / 模块 | 是否改动 | 关系 |
|---|---|---|
| 投递、Offer、面试、评分、治理、看板、匹配 | 不改 | 屏蔽不影响系统通知、投递、Offer、面试、评分、评价 |
| `MessagingService` | 仅改两方法 | `createOrGetDirectConversation`、`sendMessage` 增加屏蔽校验 |
| `listMessages` / `getConversation` / `listConversations` | 不改 | 历史消息不删除,按现有权限读取 |
| `NotificationService` | 不改语义 | 复用,处理完成后向举报人发送通知,不新增 NotificationType 枚举 |
| AI 自动审核 / 自动封禁 / 自动警告 / 公平性算法 | 不实现 | 本轮明确排除 |
## 2. 数据库(V33,三张表)
- `reports`:举报单。`(reporter_user_id, idempotency_key)` 唯一;`active_key` 唯一;`target_type` 仅 USER/MESSAGE;`status` 仅 PENDING/RESOLVED/REJECTED;`message_id` 外键 `ON DELETE SET NULL`;`reporter_user_id`/`reported_user_id` 外键关联 `users`;`@Version` 乐观锁;`evidence_urls` 使用 TEXT 存 JSON 字符串(不采用 MySQL JSON 列)。
- `block_relations`:`(blocker_user_id, blocked_user_id)` 唯一;禁止自己屏蔽自己;外键 `ON DELETE CASCADE`。
- `report_decision_history`:`report_id` 外键 `ON DELETE CASCADE`;记录 `from_status → to_status`、操作人、角色、note。
约束强校验:`target_type=USER` 时 `target_user_id` 非空且 `message_id` 为空;`target_type=MESSAGE` 反之。
## 3. 举报接口
```
POST /reports 提交举报
GET /reports/me 我的举报列表(status/page/size)
GET /reports/{id} 举报详情(本人或 ADMIN)
```
- 身份全取 JWT,不接受 `userId`/`reporterId`/`reportedUserId` 注入。
- **USER 举报**:客户端传 `targetUserId`,服务端校验存在,`targetUserId ≠ 本人`,`reportedUserId = targetUserId`。
- **MESSAGE 举报**:仅传 `messageId`,服务端校验存在 + 本人是会话参与者(无权返回 403/2002,不泄露消息是否存在),`reportedUserId` 从消息真实 sender 推导,禁止客户端覆盖。
- 路由:`/reports/me` 必须在 `/reports/{id}` 之前注册,并有路由回归测试。
幂等与防重:
| 场景 | 行为 |
|---|---|
| 同 reporter + idempotencyKey + 相同请求体 | 返回原举报,200(不新增) |
| 同 reporter + idempotencyKey + 不同请求体 | 409 / 9606 |
| 同 reporter + 同目标 + 同 category + 同 messageId/targetUserId + 已有 PENDING | 409 / 9603 |
| 已 RESOLVED/REJECTED | 不占用 active_key,允许重新提交 |
evidenceUrls 校验:最多 5 个、每个 ≤500 字符、仅 http/https;后端不主动请求/抓取 URL(防 SSRF)。
## 4. 屏蔽接口
```
PUT /blocks/{blockedUserId} 屏蔽
DELETE /blocks/{blockedUserId} 解除屏蔽(幂等)
GET /blocks/me 我的屏蔽列表(page/size)
```
- 屏蔽自己 → 400 / 9608;目标不存在复用以有用户不存在错误码。
- PUT 已存在返回 200 不新增;DELETE 不存在返回 200 不报错;GET 仅返回本人主动屏蔽的用户。
- 响应不返回对方手机号/邮箱/密码/简历地址。
- 屏蔽不影响系统通知、投递、Offer、面试、评分、评价。
## 5. MessagingService 屏蔽控制
- `createOrGetDirectConversation`:若 A 屏蔽 B 或 B 屏蔽 A 任一方向存在,禁止新建 DIRECT 会话。
- `sendMessage`:会话内任一方向存在屏蔽,禁止发送新消息。
- 对外统一 `403 / 2002 / "message cannot be sent"`,不返回“你已被屏蔽”,不泄露哪一方屏蔽了谁。
- 内部审计记录 `BLOCKED_SEND_PROHIBITED`。
- 屏蔽期间发送失败的消息直接丢弃,解除屏蔽后不补发。
- `listMessages`、`getConversation`、`listConversations` 保持现有语义。
## 6. ADMIN 举报处理接口
```
GET /admin/reports 举报列表(status/targetType/category/page/size)
GET /admin/reports/{id} 举报详情(含处理历史、脱敏证据 URL、MESSAGE 脱敏内容)
POST /admin/reports/{id}/decision 处理决定
```
- 仅 ADMIN;COMPANY / STUDENT / 未登录分别 403 / 403 / 401。
- `decision` 仅 RESOLVED / REJECTED;REJECTED 必填 `note`。
- 仅 PENDING 可处理,重复 → 409 / 9604;非法流转 → 409 / 9605;乐观锁冲突 → 409 / 9605。
- 处理不自动封禁、不删除消息、不修改投递/面试/评分状态。
- 权限矩阵:
| 身份/场景 | HTTP | code |
|---|---|---|
| 未登录 | 401 | 2001 |
| COMPANY / STUDENT | 403 | 2002 |
| ADMIN | 200 | — |
| 非本人非 ADMIN 查详情 | 403 | 2002 |
| 举报不存在 | 404 | 9601 |
## 7. 错误码
| code | 枚举 | HTTP | 说明 |
|---|---|---|---|
| 9601 | REPORT_NOT_FOUND | 404 | 举报不存在 |
| 9602 | CANNOT_REPORT_SELF | 400 | 不能举报自己 |
| 9603 | REPORT_DUPLICATE | 409 | 同目标同分类已有 PENDING |
| 9604 | ALREADY_DECIDED | 409 | 重复决定 |
| 9605 | INVALID_TRANSITION | 409 | 非法流转 / 乐观锁冲突 |
| 9606 | IDEMPOTENCY_CONFLICT | 409 | 幂等键冲突(不同请求体) |
| 9607 | REASON_REQUIRED | 400 | 理由缺失(注意复用 BAD_REQUEST 由校验层触发) |
| 9608 | CANNOT_BLOCK_SELF | 400 | 不能屏蔽自己 |
| 9609 | REPORT_RATE_LIMITED | 429 | 举报频率超限 |
| 9610 | BLOCKED_SEND_PROHIBITED | 内部 | 仅内部审计/日志,HTTP 不直接暴露 |
复用:BAD_REQUEST(1001)、UNAUTHORIZED(2001)、FORBIDDEN(2002)、用户/消息不存在错误码。
## 8. 举报限流
- 使用现有 Redis,不新增数据库表;仅限制 `POST /reports`。
- 每用户默认每小时最多 10 次;key:`report:rate:{userId}:{hour}`。
- 超限 → 429 / 9609,记录 `REPORT_RATE_LIMITED` 审计。
- 不影响屏蔽、查询、ADMIN 处理接口;不实现自动封禁。
## 9. 通知闭环
- 处理完成后向举报人发送一条通知,复用 NotificationService,不新增枚举。
- `type=REPORT_DECISION`、`businessType=REPORT`、`businessId=reportId`、`idempotencyKey=report-decision-{reportId}`。
- RESOLVED / REJECTED 均可通知;同一举报最多一条处理结果通知(幂等)。
- 举报提交时不通知被举报人,不通知被举报人的联系方式。
## 10. 审计
新增 action:`REPORT_CREATED`、`REPORT_DECIDED`、`BLOCK_CREATED`、`BLOCK_REMOVED`、`BLOCKED_SEND_PROHIBITED`、`REPORT_RATE_LIMITED`。
details 仅含:`reportId`、`userId`、`category`、`decision`、`targetType`、`messageId`、`requestId`、`timestamp`。
禁止写入:phone、email、password、JWT、resumeUrl、消息全文、文件绝对路径、evidence 文件内容、数据库连接信息。
## 11. 并发与幂等
- 举报:`(reporter_user_id, idempotency_key)` 唯一 + `active_key` 唯一 + 创建事务;并发重复仅一条成功,另一条 9603 或同幂等返回原记录。
- 屏蔽:唯一约束保证并发 PUT 不产生重复关系;DELETE 幂等;屏蔽检查与发送消息在同一业务事务内。
- 处理:`@Version` 乐观锁,冲突 409/9605。
## 12. 脱敏策略
- 响应永不返回 phone、email、passwordHash、resumeUrl、JWT、消息全文、举报人内部 ID(非 ADMIN)。
- MESSAGE 举报仅返回脱敏 `messagePreview`,不返回会话内其他敏感内容。
- 对外屏蔽发送统一模糊错误,不泄露屏蔽关系方向。
## 13. OpenAPI 变化
- 新增 8 path / 9 operation,117 → 125 paths。
- 新 path:`/reports`、`/reports/me`、`/reports/{id}`、`/blocks/{blockedUserId}`(PUT+DELETE)、`/blocks/me`、`/admin/reports`、`/admin/reports/{id}`、`/admin/reports/{id}/decision`。
- 声明:bearerAuth、请求/响应 DTO、USER/MESSAGE 枚举、PENDING/RESOLVED/REJECTED 枚举、400/401/403/404/409/429、屏蔽模糊错误、MESSAGE 需 messageId、USER 需 targetUserId。
## 14. 测试覆盖要点
1. USER 举报;2. MESSAGE 举报;3. MESSAGE 发送者服务端推导;4. 无权访问 messageId;5. 举报自己;6. 不存在 targetUserId;7. evidenceUrls 校验;8. 幂等同请求体;9. 幂等不同请求体;10. PENDING 重复;11. ADMIN 决定;12. 重复决定;13. 决定历史;14. 屏蔽/解除;15. 重复屏蔽/解除;16. 屏蔽后禁止 DIRECT;17. 屏蔽后禁止发送;18. 历史消息可读;19. 失败消息不补发;20. 频率限制;21. 通知幂等;22. 权限矩阵;23. 脱敏;24. 乐观锁并发;25. 业务数据不删除;26. V33 迁移 H2(MODE=MySQL)/MySQL 兼容。

114
docs/verify_openapi.py

@ -6,13 +6,64 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) print('paths:', len(d['paths']))
assert len(d['paths']) == 81, f"Expected 81 paths, got {len(d['paths'])}" assert len(d['paths']) == 125, f"Expected 125 paths, got {len(d['paths'])}"
paths = d['paths'] paths = d['paths']
required = [ required = [
'/review-weight-templates', '/review-weight-templates',
'/profile/me/review-weights', '/profile/me/review-weights',
'/profile/me/privacy',
'/jobs/{jobId}/personalized-review-score', '/jobs/{jobId}/personalized-review-score',
'/company/candidates',
'/company/candidates/{applicationId}',
'/profile/me/candidate-scoring',
'/profile/me/candidate-scoring/dimensions/{dimension}',
'/profile/me/candidate-scoring/trend',
'/company/candidates/{applicationId}/scoring',
'/profile/me/candidate-scoring/appeals',
'/profile/me/candidate-scoring/appeals/{appealId}',
'/profile/me/candidate-scoring/appeals/{appealId}/cancel',
'/admin/candidate-scoring/appeals',
'/admin/candidate-scoring/appeals/{appealId}/decision',
'/admin/candidate-scoring/appeals/{appealId}/rollback',
'/admin/candidate-scoring/appeals/{appealId}/history',
'/admin/candidate-scoring/snapshots/{candidateUserId}/{dimension}/{period}/history',
# 企业评分运营看板(7 个接口)
'/company/scoring/dashboard/overview',
'/company/scoring/dashboard/job-rankings',
'/company/scoring/dashboard/job-rankings/{jobId}',
'/company/scoring/dashboard/candidate-rankings',
'/company/scoring/dashboard/candidate-rankings/{applicationId}',
'/company/scoring/dashboard/trend',
'/admin/scoring/dashboard/overview',
# Offer 进阶(5 个 path / 6 个 operation)
'/offers/{id}/counter-offers',
'/offers/{id}/counter-offers/{coId}/accept',
'/offers/{id}/counter-offers/{coId}/reject',
'/offers/compare',
'/offers/statistics',
# 账号注销(2 个 path / 3 个 operation)
'/profile/me/deletion',
'/profile/me/deletion/cancel',
# 个人数据导出(3 个 path / 4 个 operation;详情已覆盖状态查询,无需 /status)
'/profile/me/data-export-tasks',
'/profile/me/data-export-tasks/{taskId}',
'/profile/me/data-export-tasks/{taskId}/download',
# 结构化面试评分(3 个 path / 4 个 operation)
'/interviews/{interviewId}/score',
'/interviews/{interviewId}/score/submit',
'/interviews/{interviewId}/score/history',
# 同岗位竞争力排名(1 个 path / 1 个 operation)
'/jobs/{jobId}/competition-ranking',
# V33 举报与屏蔽(8 个 path / 9 个 operation)
'/reports',
'/reports/me',
'/reports/{id}',
'/blocks/{blockedUserId}',
'/blocks/me',
'/admin/reports',
'/admin/reports/{id}',
'/admin/reports/{id}/decision',
] ]
for path in required: for path in required:
assert path in paths, f"Missing path: {path}" assert path in paths, f"Missing path: {path}"
@ -21,8 +72,67 @@ for path in required:
assert 'get' in paths['/review-weight-templates'], "Missing GET /review-weight-templates" assert 'get' in paths['/review-weight-templates'], "Missing GET /review-weight-templates"
assert 'get' in paths['/profile/me/review-weights'], "Missing GET /profile/me/review-weights" assert 'get' in paths['/profile/me/review-weights'], "Missing GET /profile/me/review-weights"
assert 'put' in paths['/profile/me/review-weights'], "Missing PUT /profile/me/review-weights" assert 'put' in paths['/profile/me/review-weights'], "Missing PUT /profile/me/review-weights"
assert 'get' in paths['/profile/me/privacy'], "Missing GET /profile/me/privacy"
assert 'put' in paths['/profile/me/privacy'], "Missing PUT /profile/me/privacy"
assert 'get' in paths['/jobs/{jobId}/personalized-review-score'], "Missing GET /jobs/{jobId}/personalized-review-score" assert 'get' in paths['/jobs/{jobId}/personalized-review-score'], "Missing GET /jobs/{jobId}/personalized-review-score"
print(" 4 operations all present") assert 'get' in paths['/company/candidates'], "Missing GET /company/candidates"
assert 'get' in paths['/company/candidates/{applicationId}'], "Missing GET /company/candidates/{applicationId}"
assert 'get' in paths['/profile/me/candidate-scoring'], "Missing GET /profile/me/candidate-scoring"
assert 'get' in paths['/profile/me/candidate-scoring/dimensions/{dimension}'], "Missing GET /profile/me/candidate-scoring/dimensions/{dimension}"
assert 'get' in paths['/profile/me/candidate-scoring/trend'], "Missing GET /profile/me/candidate-scoring/trend"
assert 'get' in paths['/company/candidates/{applicationId}/scoring'], "Missing GET /company/candidates/{applicationId}/scoring"
# 评分治理(9 个接口落在 8 个路径:POST+GET 共享 /appeals)
assert 'post' in paths['/profile/me/candidate-scoring/appeals'], "Missing POST /profile/me/candidate-scoring/appeals"
assert 'get' in paths['/profile/me/candidate-scoring/appeals'], "Missing GET /profile/me/candidate-scoring/appeals"
assert 'get' in paths['/profile/me/candidate-scoring/appeals/{appealId}'], "Missing GET .../appeals/{appealId}"
assert 'post' in paths['/profile/me/candidate-scoring/appeals/{appealId}/cancel'], "Missing POST .../cancel"
assert 'get' in paths['/admin/candidate-scoring/appeals'], "Missing GET /admin/candidate-scoring/appeals"
assert 'post' in paths['/admin/candidate-scoring/appeals/{appealId}/decision'], "Missing POST .../decision"
assert 'post' in paths['/admin/candidate-scoring/appeals/{appealId}/rollback'], "Missing POST .../rollback"
assert 'get' in paths['/admin/candidate-scoring/appeals/{appealId}/history'], "Missing GET .../history"
assert 'get' in paths['/admin/candidate-scoring/snapshots/{candidateUserId}/{dimension}/{period}/history'], "Missing GET snapshots history"
# 企业评分运营看板(7 个 GET)
assert 'get' in paths['/company/scoring/dashboard/overview'], "Missing GET /company/scoring/dashboard/overview"
assert 'get' in paths['/company/scoring/dashboard/job-rankings'], "Missing GET /company/scoring/dashboard/job-rankings"
assert 'get' in paths['/company/scoring/dashboard/job-rankings/{jobId}'], "Missing GET /company/scoring/dashboard/job-rankings/{jobId}"
assert 'get' in paths['/company/scoring/dashboard/candidate-rankings'], "Missing GET /company/scoring/dashboard/candidate-rankings"
assert 'get' in paths['/company/scoring/dashboard/candidate-rankings/{applicationId}'], "Missing GET /company/scoring/dashboard/candidate-rankings/{applicationId}"
assert 'get' in paths['/company/scoring/dashboard/trend'], "Missing GET /company/scoring/dashboard/trend"
assert 'get' in paths['/admin/scoring/dashboard/overview'], "Missing GET /admin/scoring/dashboard/overview"
# Offer 进阶(6 个 operation / 5 个 path)
assert 'post' in paths['/offers/{id}/counter-offers'], "Missing POST /offers/{id}/counter-offers"
assert 'get' in paths['/offers/{id}/counter-offers'], "Missing GET /offers/{id}/counter-offers"
assert 'patch' in paths['/offers/{id}/counter-offers/{coId}/accept'], "Missing PATCH .../accept"
assert 'patch' in paths['/offers/{id}/counter-offers/{coId}/reject'], "Missing PATCH .../reject"
assert 'get' in paths['/offers/compare'], "Missing GET /offers/compare"
assert 'get' in paths['/offers/statistics'], "Missing GET /offers/statistics"
# 账号注销(3 个 operation)
assert 'get' in paths['/profile/me/deletion'], "Missing GET /profile/me/deletion"
assert 'post' in paths['/profile/me/deletion'], "Missing POST /profile/me/deletion"
assert 'post' in paths['/profile/me/deletion/cancel'], "Missing POST /profile/me/deletion/cancel"
# 个人数据导出(4 个 operation / 3 个 path)
assert 'post' in paths['/profile/me/data-export-tasks'], "Missing POST /profile/me/data-export-tasks"
assert 'get' in paths['/profile/me/data-export-tasks'], "Missing GET /profile/me/data-export-tasks"
assert 'get' in paths['/profile/me/data-export-tasks/{taskId}'], "Missing GET /profile/me/data-export-tasks/{taskId}"
assert 'get' in paths['/profile/me/data-export-tasks/{taskId}/download'], "Missing GET .../download"
print(" Personal data export endpoints present (4 operations / 3 paths)")
# 同岗位竞争力排名(1 个 GET operation)
assert 'get' in paths['/jobs/{jobId}/competition-ranking'], "Missing GET /jobs/{jobId}/competition-ranking"
print(" Competition ranking endpoint present (1 GET operation)")
# V33 举报与屏蔽(8 个 path / 9 个 operation;PUT+DELETE 共享 /blocks/{blockedUserId})
assert 'post' in paths['/reports'], "Missing POST /reports"
assert 'get' in paths['/reports/me'], "Missing GET /reports/me"
assert 'get' in paths['/reports/{id}'], "Missing GET /reports/{id}"
assert 'put' in paths['/blocks/{blockedUserId}'], "Missing PUT /blocks/{blockedUserId}"
assert 'delete' in paths['/blocks/{blockedUserId}'], "Missing DELETE /blocks/{blockedUserId}"
assert 'get' in paths['/blocks/me'], "Missing GET /blocks/me"
assert 'get' in paths['/admin/reports'], "Missing GET /admin/reports"
assert 'get' in paths['/admin/reports/{id}'], "Missing GET /admin/reports/{id}"
assert 'post' in paths['/admin/reports/{id}/decision'], "Missing POST /admin/reports/{id}/decision"
print(" V33 report/block endpoints present (9 operations / 8 paths)")
print(" Offer advance endpoints all present")
print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)")
data = json.dumps(d) data = json.dumps(d)
assert '"" : "#/components/schemas/' not in data, "Found empty ref key" assert '"" : "#/components/schemas/' not in data, "Found empty ref key"

531
mydreamjob-server/docs/backend-development-roadmap.md

@ -0,0 +1,531 @@
# 职来后端后续开发规划与 Figma 差距修复方案
> 文档状态:已确认,作为后续开发排期依据
>
> 确认日期:2026-08-25
>
> 适用项目:职来(mydreamjob-server)
> **正式接口文档目录**:`C:\Users\15642\Desktop\zhilai\docs`(OpenAPI、前端联调、验收、各模块 API 文档的唯一正式目录)。
> 本文档 `mydreamjob-server\docs\backend-development-roadmap.md` 仅保留为「路线图源文件」,不移动、不删除任何文档。
## 1. 文档目的
本文档用于统一 Figma UI、产品功能表和后端实现之间的差距,明确后端后续开发顺序。
本规划遵循以下原则:
1. 先修复已经实现但存在安全或契约问题的内容;
2. 再开发完全缺失的新功能;
3. 不破坏已验收接口语义;
4. 不直接修改已有字段类型;
5. 每个模块由开发者完成代码、自动化测试、黑盒验收和文档更新;
6. 未经负责人确认,不开始下一模块。
## 2. 当前基线
| 项目 | 当前状态 |
|---|---|
| OpenAPI | **125 paths、159 operations** |
| Flyway | **V1–V33** 已存在 |
| 全量测试 | **1255/1255 通过**(本次 `mvn clean verify` 实际数量) |
| 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 |
| 当前结论 | 已达到已实现范围的 Release Candidate;V33 待负责人 MySQL/Docker 黑盒复验 |
| 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 |
### 2.0 模块状态分类
| 类别 | 模块 |
|---|---|
| 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking |
| 已完成、待真实 MySQL/Docker 验收 | **V33 Report/Block**(自动化通过;限流/屏蔽拒发的拒绝型安全审计独立事务已实现。MySQL 黑盒:**待复验**——本次复验被凭据环境阻塞,需负责人提供既有启动配置后复验) |
| 已设计未编码 | 测评系统(Holland/能力测评)、公平性检测、SHAP/规则解释、防刷审计、企业评价管理、企业人才推荐、统一求职进度聚合 |
| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 |
### 2.1 已确认的产品决策
| 事项 | 决策 |
|---|---|
| 同岗位竞争力排名 | 批准开发,排在隐私模块之后 |
| 结构化面试评分 | 批准独立开发,不复用 `candidate_feedback` |
| 隐私设置 | 批准 |
| 账号注销 | 已批准并统一为 **30 天**冷静期(`app.deletion.cooling-days` / `DELETION_COOLING_DAYS`,默认 30)。到期后完成账号 PII 脱敏并保留业务记录与审计轨迹。 |
| 个人数据导出 | 批准,采用异步导出任务 |
| 防刷审计报告 | 仅 ADMIN 可见 |
| 公平性检测 | 纳入后续范围 |
| SHAP/规则解释 | 纳入后续范围,后置实现 |
| 反刷能力 | 纳入后续范围,先做确定性规则型方案 |
| `skills` 字段 | 旧接口继续保持 String,后续可增加兼容字段 `skillsList` |
| Offer 综合比较 | 暂由前端展示,后续再决定是否由后端计算 |
| Figma 重复页面 | 视为同一功能的不同状态或设计稿,不新增重复接口 |
## 3. 当前已实现功能
### 3.1 求职端
- 认证与登录;
- 个人资料、求职偏好;
- 岗位搜索、筛选、比较、收藏;
- 岗位评价和评价审核;
- 评价权重设置;
- 投递和投递时间线;
- Offer 基础与进阶功能;
- 面试流程;
- 通知中心、站内信和直聊;
- 双向匹配;
- 简历版本管理和简历导出;
- 求职者评分卡;
- Candidate Scoring Engine;
- 评分申诉、审核和回滚。
### 3.2 企业端
- 企业资料和企业认证;
- 岗位发布和管理;
- 企业公开主页;
- 企业候选人看板;
- 企业评分运营看板;
- 候选人评分详情;
- Offer 管理;
- 面试管理;
- 评价审核基础能力;
- 双向匹配、通知和直聊。
## 4. Figma 与后端覆盖结论
### 4.1 已完成或基本完成
| Figma 页面/功能 | 当前后端接口 | 状态 |
|---|---|---|
| 首页、岗位搜索、岗位详情 | `/jobs`、`/jobs/search`、`/jobs/filters`、`/jobs/{id}`、`/companies/{id}/jobs` | 已完成 |
| 岗位收藏 | `/jobs/favorites`、`POST/DELETE /jobs/{jobId}/favorite` | 已完成 |
| 岗位对比 | `/jobs/compare` | 已完成 |
| 岗位评价、评价详情 | `/jobs/{jobId}/reviews`、`/summary`、评价 CRUD | 基本完成 |
| 评价权重设置 | `/profile/me/review-weights`、`/review-weight-templates` | 已完成 |
| 通知中心 | `/notifications`、`/unread-count`、`/{id}/read`、`/read-all` | 已完成 |
| 站内信、HR 聊天 | `/conversations`、`/conversations/messages`、`/read` | 已完成 |
| 双向匹配 | 匹配规则、评分、`/applications/{id}/match`、会话接口 | 已完成 |
| 面试安排 | `/interviews*`、`/applications/{id}/interviews` | 已完成 |
| Offer 基础和进阶 | `/offers`、状态、时间线、提醒、还价、比较、统计 | 已完成基础能力 |
| 个人资料、求职偏好 | `/profile/me`、`PUT /profile/me`、`/profile/me/preferences` | 已完成 |
| 简历版本和简历导出 | `/profile/me/resume-versions`、`/resume-export-tasks` | 已完成,但不是个人数据导出 |
| 求职者评分卡 | `/profile/me/candidate-score-card`、`candidate-scoring*` | 已完成 |
| 评分申诉、审核、回滚 | `/profile/me/candidate-scoring/appeals`、`/admin/candidate-scoring/appeals*` | 已完成 |
| 企业候选人看板 | `/company/candidates`、`/company/candidates/{id}` | 已完成 |
| 企业评分看板 | `/company/scoring/dashboard/*` | 已完成 |
| 企业认证 | `/companies/me/verification`、`/admin/companies/verifications*` | 已完成 |
### 4.2 部分完成或口径不一致
| Figma 页面/功能 | 当前实现 | 差距 |
|---|---|---|
| 同岗位竞争力排名 | `GET /jobs/{jobId}/competition-ranking` 已实现 | 已交付;最终口径:WITHDRAWN/REJECTED 退出候选人池(请求 403/2002);无评分但有效投递返回 200 + null 分数;Top5 匿名。不宣称"支持隐私退出" |
| 求职进度 | applications、timeline、offers、interviews 可组合 | 缺统一的求职进度聚合接口 |
| Offer 对比 | `/offers/compare` 返回 Offer 字段 | 综合薪资、福利、发展空间当前由前端展示,后端不计算综合结论 |
| HR 评价详情 | `candidate_feedback` 已实现 | 不是结构化面试评分 |
| 企业评价管理 | 有评价审核和管理员决策 | 缺企业端评价管理、回复和运营动作 |
| 企业人才推荐 | 有候选人列表和评分 | 缺推荐排序、推荐理由和可解释结果 |
| 运营规则配置 | Figma 有页面 | 后端暂无规则配置接口 |
| 防刷审计报告 | 已有评价审核基础能力 | 不是风险等级、风险趋势、风险分布和事件审计报告 |
### 4.3 完全缺失
- 霍兰德职业兴趣测评;
- 四维能力测评;
- 测评结果和测评岗位推荐;
- AI 简历诊断和优化;
- AI 模拟面试和面试题预测;
- 行业技能词云;
- 入职准备清单和公司知识介绍;
- 薪资计算器和薪资参考;
- 公平性检测;
- SHAP/规则解释;
- ADMIN 防刷审计和规则配置;
- 诚信积分和激励。
- 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)。
## 5. 已有代码的优先修复项
### 5.1 CandidateFeedback 响应脱敏(第一项)
当前反馈响应不能向前端暴露以下字段:
- `companyUserId`;
- `studentUserId`;
- `phone`;
- `email`;
- `passwordHash`;
- `resumeUrl`。
建议使用白名单响应 DTO:
```json
{
"id": 1,
"applicationId": 31,
"rating": 5,
"content": "沟通良好",
"tags": ["沟通良好"],
"createdAt": "2026-08-25T10:00:00"
}
```
修复约束:
- 不修改请求格式;
- 不修改路径、权限和错误码;
- 不修改 `candidate_feedback` 表;
- 不新增 Flyway;
- 增加企业、学生、越权、未登录和脱敏测试;
- 更新 OpenAPI response schema 和前端联调文档。
### 5.2 只需统一文档,不应误改业务语义
| 内容 | 处理方式 |
|---|---|
| `skills` 为 String | 保持旧接口兼容,文档说明解析方式,后续再增加 `skillsList` |
| `PENDING`/`INSUFFICIENT_DATA` | 属于评分设计状态,不转成 0 分 |
| `/resume-export-tasks` | 明确为简历导出,不当作个人数据导出 |
| Offer 综合比较 | 暂由前端展示,不新增后端综合评分 |
| Figma 重复页面 | 作为同一接口的不同状态处理 |
## 6. 后续模块设计与顺序
### 阶段 0:已有代码和契约修复
#### 模块 0.1:CandidateFeedback 脱敏
不新增接口、数据库表或 Flyway。完成后要求专项测试、全量测试、OpenAPI 校验和黑盒脱敏验证全部通过。
### 阶段 1:隐私与账户合规
#### 模块 1.1:隐私设置
接口:
```text
GET /profile/me/privacy
PUT /profile/me/privacy
```
落地现状(V27 Privacy 已实现,真实能力**仅两项**):
- `resumeVisibility`(简历可见性);
- `contactVisible`(企业是否可联系)。
以下为**未来“企业人才推荐/细粒度授权”设计项,尚未实现,不得伪称已提供**:
- 退出同岗位排名;
- 退出企业人才推荐。
> ⚠️ V27 隐私设置接口已验收;后续如需新增“退出同岗位排名 / 退出企业人才推荐”,属于新设计,需随“企业人才推荐/细粒度授权”一并评审,不得在未开发前宣称已支持。
建议表和迁移(如未来扩展):
```text
student_privacy_settings
(V27 已落地,未新增字段)
```
#### 模块 1.2:账号注销
接口:
```text
POST /profile/me/deletion
GET /profile/me/deletion
POST /profile/me/deletion/cancel
```
规则:
- 申请后进入 `PENDING`;
- 冷静期统一为 **30 天**(`app.deletion.cooling-days` / `DELETION_COOLING_DAYS`,默认 30);到期后执行账号 PII 脱敏,业务记录与审计轨迹按既有保留策略留存;
- 冷静期支持取消;
- 到期后执行删除、匿名化或保留策略;
- 全流程写入审计日志。
#### 模块 1.3:个人数据导出
接口:
```text
POST /profile/me/data-export-tasks
GET /profile/me/data-export-tasks
GET /profile/me/data-export-tasks/{taskId}
GET /profile/me/data-export-tasks/{taskId}/download
```
要求:
- 不复用 `/resume-export-tasks`;
- 使用异步任务;
- 不在 HTTP 请求内同步打包全部数据;
- 导出内容不得包含密码、JWT、验证码等认证敏感信息。
建议表和迁移:
```text
personal_data_export_tasks
V28__personal_data_export.sql
```
### 阶段 2:结构化面试评分
结构化面试评分独立于 `candidate_feedback`。
接口:
```text
POST /interviews/{interviewId}/score
GET /interviews/{interviewId}/score
PUT /interviews/{interviewId}/score
GET /interviews/{interviewId}/score/history
```
权限:
| 角色 | 权限 |
|---|---|
| COMPANY | 本企业面试可创建、修改、查看 |
| STUDENT | 只能查看本人面试评分 |
| ADMIN | 只读审计 |
| 其他用户 | 403 |
建议表和迁移:
```text
interview_scores
interview_score_history
V29__interview_scoring.sql
```
### 阶段 3:同岗位竞争力排名
接口:
```text
GET /jobs/{jobId}/competition-ranking
```
必须支持:
- 当前学生在指定岗位的排名;
- cohort 总数;
- 百分位;
- 维度百分位;
- Top 候选人;
- 改进建议;
- 样本不足;
- 无评分候选人;
- 投递状态过滤(WITHDRAWN/REJECTED 退出候选人池,请求返回 403/2002);
- 候选人信息脱敏。
必须复用:
```text
CandidateScoringCalculator
candidate_dimension_scores
applications
jobs
```
> ⚠️ V32 已编码并验收。**最终口径**:仅 `SUBMITTED / VIEWED / INTERVIEW / HIRED` 有效投递可进入;`WITHDRAWN / REJECTED` 已退出候选人池(请求 403/2002);无评分但有效投递返回 200 + `rank/overallScore/percentile=null`。**不宣称支持“隐私退出”**;排名不使用 `student_privacy_settings`。
初期可以不新增数据库表。
### 阶段 4:测评系统
开发顺序:
1. 霍兰德职业兴趣测评;
2. 四维能力测评;
3. 测评答题和进度保存;
4. 测评结果;
5. 测评结果进入 ABILITY/GROWTH;
6. 根据测评结果推荐岗位。
建议接口:
```text
GET /assessments/types
GET /assessments/{type}/questions
POST /assessments/attempts
POST /assessments/attempts/{id}/submit
GET /assessments/attempts/{id}/result
GET /profile/me/assessment-results
```
建议表:
```text
assessment_definitions
assessment_questions
assessment_attempts
assessment_answers
assessment_results
```
建议迁移:
```text
V30__assessment.sql
```
### 阶段 5:公平性、SHAP、反刷审计
#### 防刷审计
仅 ADMIN 可见:
```text
GET /admin/scoring/risk/overview
GET /admin/scoring/risk/trend
GET /admin/scoring/risk/distribution
GET /admin/scoring/risk/events
GET /admin/scoring/rules
PUT /admin/scoring/rules/{id}
```
禁止返回原始手机号、IP、设备指纹、密码、Token 等敏感信息。
建议表:
```text
scoring_risk_events
scoring_rule_configs
scoring_risk_actions
```
#### 公平性检测
第一阶段做统计检测:
- 不同群体平均分差异;
- 通过率差异;
- 排名差异;
- 样本不足提示;
- 异常分布提示。
#### SHAP/规则解释
先实现确定性规则解释,不让前端依赖 AI 模型结果:
```json
{
"dimension": "EDUCATION",
"reasons": [
"学历字段完整",
"学校信息缺失",
"毕业年份有效"
]
}
```
### 阶段 6:企业运营模块
开发顺序:
1. 企业评价管理;
2. 企业评价回复;
3. 运营规则配置;
4. 企业人才推荐;
5. 推荐理由和解释;
6. 企业运营数据增强。
### 阶段 7:AI 和增值功能
- AI 简历诊断;
- AI 简历优化;
- AI 模拟面试;
- 面试题预测;
- 行业技能词云;
- 入职准备清单;
- 公司知识介绍;
- 简历查重;
- 诚信积分;
- 消息撤回。
## 7. 总体优先级
| 优先级 | 模块 |
|---|---|
| P0 | CandidateFeedback 脱敏 |
| P0 | 隐私设置 |
| P0 | 账号注销 |
| P0 | 个人数据导出 |
| P0 | 结构化面试评分 |
| P0/P1 | 同岗位竞争力排名 |
| P1 | 霍兰德和四维能力测评 |
| P1 | 统一求职进度 |
| P1 | 企业评价管理 |
| P1 | 企业人才推荐 |
| P1 | 公平性检测 |
| P1 | ADMIN 防刷审计 |
| P2 | SHAP 高级解释 |
| P2 | AI 简历、AI 模拟面试 |
| P2 | 技能词云、入职清单、诚信积分 |
## 8. 每个模块的统一交付要求
每个模块必须交付:
1. 接口设计;
2. 请求和响应 DTO;
3. Controller;
4. Service;
5. Repository;
6. Flyway 迁移;
7. 错误码;
8. 权限矩阵;
9. 脱敏规则;
10. 幂等设计;
11. 并发控制;
12. Service 单元测试;
13. MockMvc 测试;
14. 持久化测试;
15. Docker/MySQL 黑盒验收;
16. OpenAPI 更新;
17. 前端联调文档;
18. 已验收模块回归测试。
## 9. 第一阶段执行指令
下一步只执行 CandidateFeedback 脱敏,不开发隐私设置、面试评分或同岗位排名。
```text
当前先修复已有 CandidateFeedback 接口的响应脱敏问题,不开发新业务模块。
问题:
CandidateFeedback 当前响应包含 companyUserId、studentUserId 等内部身份字段,不符合前端契约和数据脱敏要求。
要求:
1. 先检查 CandidateFeedbackController、CandidateFeedbackService、现有 View/DTO、Repository、测试和 OpenAPI。
2. 新增白名单 CandidateFeedbackResponse DTO。
3. 保持现有接口路径、请求参数、权限、错误码和数据库结构不变。
4. 学生和企业响应都不得返回 companyUserId、studentUserId、phone、email、passwordHash、resumeUrl。
5. 不修改 candidate_feedback 表。
6. 不新增 Flyway。
7. 不启动或停止 Docker。
8. 补充 COMPANY、STUDENT、其他学生、其他企业、未登录和脱敏测试。
9. 更新 OpenAPI response schema 和 frontend-integration.md。
10. 执行 CandidateFeedback 专项测试、mvn test、mvn clean verify、python docs/verify_openapi.py。
11. 完成后停止,不要继续开发隐私设置、面试评分或同岗位排名。
最后只输出:修改文件、测试结果、OpenAPI 路径数、脱敏验证结果、是否还有问题、等待下一步指令。
```
## 10. 变更控制
未经负责人再次确认,不得:
- 修改已验收接口的响应语义;
- 将 `skills` 从 String 改成数组;
- 把 CandidateFeedback 当作面试评分;
- 把 `/resume-export-tasks` 当作个人数据导出;
- 把 percentile 当作同岗位排行榜;
- 把评价审核当作完整防刷审计;
- 让 COMPANY 查看防刷审计报告;
- 新增未批准的 Flyway;
- 删除或清理业务数据;
- 启动或停止 Docker。

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

@ -3,9 +3,12 @@ package com.mydreamjob.application;
import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.application.dto.CandidateDetailView;
import com.mydreamjob.application.dto.CandidateListResponse;
import com.mydreamjob.application.dto.ApplicationCreateRequest; import com.mydreamjob.application.dto.ApplicationCreateRequest;
import com.mydreamjob.application.dto.ApplicationStatusRequest; import com.mydreamjob.application.dto.ApplicationStatusRequest;
import com.mydreamjob.company.CompanyService; import com.mydreamjob.company.CompanyService;
import com.mydreamjob.auth.UserRepository;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@ -20,6 +23,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Tag(name = "投递", description = "学生投递、撤回、查询;企业查看和审核投递;时间线") @Tag(name = "投递", description = "学生投递、撤回、查询;企业查看和审核投递;时间线")
@ -29,6 +33,12 @@ public class ApplicationController {
private final ApplicationService applicationService; private final ApplicationService applicationService;
private final CompanyService companyService; private final CompanyService companyService;
private final UserRepository userRepository;
private static final Set<String> ALLOWED_STATUSES = Set.of(
"SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN");
private static final Set<String> ALLOWED_SORTS = Set.of("createdAt", "updatedAt");
private static final Set<String> ALLOWED_ORDERS = Set.of("asc", "desc");
// ── 学生投递 ────────────────────────────────────────────── // ── 学生投递 ──────────────────────────────────────────────
@ -137,8 +147,72 @@ public class ApplicationController {
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), getRequestId(request))); return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), getRequestId(request)));
} }
// ── 企业候选人看板 ────────────────────────────────────────
@Operation(summary = "企业候选人列表", description = "跨岗位查询本企业所有候选人投递,支持按岗位、状态、关键词筛选,支持分页和排序")
@GetMapping("/company/candidates")
public ResponseEntity<ApiResponse<CandidateListResponse>> listCompanyCandidates(
@RequestParam(required = false) Long jobId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String sort,
@RequestParam(required = false) String order,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId(request);
assertCompanyRole(userId);
// 参数校验
if (page < 0 || size < 1 || size > 100)
throw new BusinessException(ErrorCode.BAD_REQUEST);
if (jobId != null && jobId <= 0)
throw new BusinessException(ErrorCode.BAD_REQUEST);
if (status != null && !ALLOWED_STATUSES.contains(status))
throw new BusinessException(ErrorCode.BAD_REQUEST);
if (sort != null && !ALLOWED_SORTS.contains(sort))
throw new BusinessException(ErrorCode.BAD_REQUEST);
if (order != null && !ALLOWED_ORDERS.contains(order))
throw new BusinessException(ErrorCode.BAD_REQUEST);
if (keyword != null) {
keyword = keyword.trim();
if (keyword.isEmpty()) keyword = null;
else if (keyword.length() > 100)
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
var company = companyService.findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
CandidateListResponse data = applicationService.listCompanyCandidates(
company.getId(), jobId, status, keyword, sort, order, page, size);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "企业候选人详情", description = "查看投递详情及候选人公开资料,只能查看本企业岗位下的投递")
@GetMapping("/company/candidates/{applicationId}")
public ResponseEntity<ApiResponse<CandidateDetailView>> getCompanyCandidateDetail(
@PathVariable Long applicationId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
assertCompanyRole(userId);
var company = companyService.findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
CandidateDetailView data = applicationService.getCompanyCandidateDetail(
company.getId(), applicationId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
// ── 辅助 ────────────────────────────────────────────────── // ── 辅助 ──────────────────────────────────────────────────
private void assertCompanyRole(Long userId) {
var user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getRoles().stream().noneMatch(r -> r.name().equals("COMPANY")))
throw new BusinessException(ErrorCode.FORBIDDEN);
}
private Long resolveUserId(HttpServletRequest request) { private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) if (auth == null || !auth.isAuthenticated())

13
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java

@ -26,4 +26,17 @@ public interface ApplicationRepository extends JpaRepository<Application, Long>
Page<Application> findByJobIdAndStatusOrderByCreatedAtDesc(Long jobId, String status, Pageable pageable); Page<Application> findByJobIdAndStatusOrderByCreatedAtDesc(Long jobId, String status, Pageable pageable);
boolean existsByJobIdAndStudentUserId(Long jobId, Long studentUserId); boolean existsByJobIdAndStudentUserId(Long jobId, Long studentUserId);
/**
* 同岗位竞争排名:判断学生是否存在某岗位的「有效投递」(状态不在排除集合内,
* 即非 WITHDRAWN/REJECTED)。与 {@link #findByJobIdAndStatusNotInOrderByCreatedAtAsc}
* 共用同一排除口径,避免"曾投递但已失效"误放行导致返回 200。
*/
boolean existsByJobIdAndStudentUserIdAndStatusNotIn(Long jobId, Long studentUserId,
java.util.Collection<String> statuses);
/**
* 同岗位 rank 用:取某岗位未排除(非 WITHDRAWN/REJECTED)的投递,按投递时间正序。
*/
List<Application> findByJobIdAndStatusNotInOrderByCreatedAtAsc(Long jobId, java.util.Collection<String> statuses);
} }

185
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java

@ -1,5 +1,10 @@
package com.mydreamjob.application; package com.mydreamjob.application;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.application.dto.CandidateDetailView;
import com.mydreamjob.application.dto.CandidateItemView;
import com.mydreamjob.application.dto.CandidateListResponse;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
@ -10,6 +15,10 @@ import com.mydreamjob.job.JobService;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.notification.NotificationService; import com.mydreamjob.notification.NotificationService;
import com.mydreamjob.profile.StudentProfile;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.TypedQuery;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@ -18,9 +27,12 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Slf4j @Slf4j
@ -35,6 +47,15 @@ public class ApplicationService {
private final ApplicationStatusHistoryRepository historyRepository; private final ApplicationStatusHistoryRepository historyRepository;
private final NotificationService notificationService; private final NotificationService notificationService;
@PersistenceContext
private EntityManager em;
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final Set<String> ALLOWED_STATUSES = Set.of(
"SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN");
private static final Set<String> ALLOWED_SORTS = Set.of("createdAt", "updatedAt");
private static final Set<String> ALLOWED_ORDERS = Set.of("asc", "desc");
// ── 学生端 ──────────────────────────────────────────────── // ── 学生端 ────────────────────────────────────────────────
@Transactional @Transactional
@ -224,4 +245,168 @@ public class ApplicationService {
.fromStatus(fromStatus).toStatus(toStatus).reason(reason) .fromStatus(fromStatus).toStatus(toStatus).reason(reason)
.build()); .build());
} }
// ── 企业候选人看板 ─────────────────────────────────────────
/**
* 企业候选人列表:跨岗位查询,JOIN student_profiles + users
* 排序字段通过白名单映射,禁止拼接原始请求参数
*/
@Transactional(readOnly = true)
public CandidateListResponse listCompanyCandidates(Long companyId, Long jobId, String status,
String keyword, String sort, String order,
int page, int size) {
// 参数校验
if (page < 0 || size < 1 || size > 100)
throw new BusinessException(ErrorCode.BAD_REQUEST);
if (status != null && !ALLOWED_STATUSES.contains(status))
throw new BusinessException(ErrorCode.BAD_REQUEST);
if (sort != null && !ALLOWED_SORTS.contains(sort))
throw new BusinessException(ErrorCode.BAD_REQUEST);
if (order != null && !ALLOWED_ORDERS.contains(order))
throw new BusinessException(ErrorCode.BAD_REQUEST);
String escapedKeyword = escapeLike(keyword);
// 白名单映射排序字段
String sortField = (sort == null) ? "a.createdAt" : "a." + sort;
String sortOrder = (order == null) ? "DESC" : order.toUpperCase();
// 构建 JPQL
String whereClause = "WHERE j.companyId = :companyId";
if (jobId != null) whereClause += " AND a.jobId = :jobId";
if (status != null) whereClause += " AND a.status = :status";
if (escapedKeyword != null) {
whereClause += " AND (sp.realName LIKE CONCAT('%', :keyword, '%')" +
" OR sp.school LIKE CONCAT('%', :keyword, '%')" +
" OR sp.education LIKE CONCAT('%', :keyword, '%'))";
}
String selectClause = "SELECT a.id, a.jobId, j.title, a.status, " +
"sp.realName, sp.school, sp.education, sp.graduationYear, " +
"u.avatarUrl, a.createdAt, a.updatedAt";
String fromClause = "FROM Application a " +
"JOIN Job j ON a.jobId = j.id " +
"LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " +
"LEFT JOIN User u ON a.studentUserId = u.id";
String countJpql = "SELECT COUNT(a) " + fromClause + " " + whereClause;
String dataJpql = selectClause + " " + fromClause + " " + whereClause +
" ORDER BY " + sortField + " " + sortOrder + ", a.id " + sortOrder;
// Count
TypedQuery<Long> countQuery = em.createQuery(countJpql, Long.class);
countQuery.setParameter("companyId", companyId);
if (jobId != null) countQuery.setParameter("jobId", jobId);
if (status != null) countQuery.setParameter("status", status);
if (escapedKeyword != null) countQuery.setParameter("keyword", escapedKeyword);
long total = countQuery.getSingleResult();
// Data
TypedQuery<Object[]> dataQuery = em.createQuery(dataJpql, Object[].class);
dataQuery.setParameter("companyId", companyId);
if (jobId != null) dataQuery.setParameter("jobId", jobId);
if (status != null) dataQuery.setParameter("status", status);
if (escapedKeyword != null) dataQuery.setParameter("keyword", escapedKeyword);
dataQuery.setFirstResult(page * size);
dataQuery.setMaxResults(size);
List<Object[]> rows = dataQuery.getResultList();
List<CandidateItemView> items = rows.stream().map(row -> CandidateItemView.builder()
.applicationId((Long) row[0])
.jobId((Long) row[1])
.jobTitle((String) row[2])
.status((String) row[3])
.candidateName((String) row[4])
.candidateSchool((String) row[5])
.candidateEducation((String) row[6])
.candidateGraduationYear(row[7] != null ? ((Number) row[7]).shortValue() : null)
.candidateAvatarUrl((String) row[8])
.createdAt((LocalDateTime) row[9])
.updatedAt((LocalDateTime) row[10])
.build()).collect(Collectors.toList());
return CandidateListResponse.builder()
.items(items)
.total(total)
.page(page)
.size(size)
.totalPages((int) Math.ceil((double) total / size))
.build();
}
/**
* 企业候选人详情:单次 JOIN 查询,不产生 N+1
*/
@Transactional(readOnly = true)
public CandidateDetailView getCompanyCandidateDetail(Long companyId, Long applicationId) {
// 单次 JOIN 查询:applications + jobs + student_profiles + users
// 查询结果包含岗位 companyId,数据隔离在 Service 层比较,避免额外的 findById 二次查询
TypedQuery<Object[]> query = em.createQuery(
"SELECT a, j, sp, u FROM Application a " +
"JOIN Job j ON a.jobId = j.id " +
"LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " +
"LEFT JOIN User u ON a.studentUserId = u.id " +
"WHERE a.id = :applicationId",
Object[].class);
query.setParameter("applicationId", applicationId);
List<Object[]> results = query.getResultList();
if (results.isEmpty()) {
// 投递不存在
throw new BusinessException(ErrorCode.APPLICATION_NOT_FOUND);
}
Object[] row = results.get(0);
Application app = (Application) row[0];
com.mydreamjob.job.Job job = (com.mydreamjob.job.Job) row[1];
StudentProfile sp = (StudentProfile) row[2];
com.mydreamjob.auth.User u = (com.mydreamjob.auth.User) row[3];
// 数据隔离:投递存在但岗位不属于当前企业 → 越权
if (!job.getCompanyId().equals(companyId)) {
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
}
List<String> skills = Collections.emptyList();
if (sp != null && sp.getSkillsJson() != null) {
try {
skills = objectMapper.readValue(sp.getSkillsJson(),
new TypeReference<List<String>>() {});
} catch (Exception e) {
skills = Collections.emptyList();
}
}
return CandidateDetailView.builder()
.applicationId(app.getId())
.jobId(app.getJobId())
.jobTitle(job.getTitle())
.jobCity(job.getCity())
.jobEmploymentType(job.getEmploymentType())
.status(app.getStatus())
.coverLetter(app.getCoverLetter())
.createdAt(app.getCreatedAt())
.updatedAt(app.getUpdatedAt())
.candidateName(sp != null ? sp.getRealName() : null)
.candidateAvatarUrl(u != null ? u.getAvatarUrl() : null)
.candidateBio(sp != null ? sp.getBio() : null)
.candidateLocation(sp != null ? sp.getLocation() : null)
.candidateSchool(sp != null ? sp.getSchool() : null)
.candidateEducation(sp != null ? sp.getEducation() : null)
.candidateGraduationYear(sp != null ? sp.getGraduationYear() : null)
.candidateSkills(skills)
.build();
}
/**
* 转义 LIKE 特殊字符,防止用户输入 %、_ 等通配符
*/
String escapeLike(String keyword) {
if (keyword == null) return null;
return keyword
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_");
}
} }

40
mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java

@ -0,0 +1,40 @@
package com.mydreamjob.application.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 企业候选人详情视图 — 不返回 studentUserId、phone、passwordHash、email、resumeUrl、内部字段
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CandidateDetailView {
// 投递信息
private Long applicationId;
private Long jobId;
private String jobTitle;
private String jobCity;
private String jobEmploymentType;
private String status;
private String coverLetter;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// 候选人信息
private String candidateName;
private String candidateAvatarUrl;
private String candidateBio;
private String candidateLocation;
private String candidateSchool;
private String candidateEducation;
private Short candidateGraduationYear;
private List<String> candidateSkills;
}

30
mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java

@ -0,0 +1,30 @@
package com.mydreamjob.application.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 企业候选人列表项视图 — 不返回 studentUserId、phone、coverLetter、内部字段
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CandidateItemView {
private Long applicationId;
private Long jobId;
private String jobTitle;
private String status;
private String candidateName;
private String candidateSchool;
private String candidateEducation;
private Short candidateGraduationYear;
private String candidateAvatarUrl;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

24
mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateListResponse.java

@ -0,0 +1,24 @@
package com.mydreamjob.application.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 企业候选人列表分页响应
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CandidateListResponse {
private List<CandidateItemView> items;
private long total;
private int page;
private int size;
private int totalPages;
}

65
mydreamjob-server/src/main/java/com/mydreamjob/audit/SecurityAuditService.java

@ -0,0 +1,65 @@
package com.mydreamjob.audit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* 安全审计服务:以独立事务({@code REQUIRES_NEW})写入拒绝型安全事件,
* 即使外层业务事务因 403/429 等异常回滚,本记录仍能落库,保证可追踪恶意举报与骚扰尝试。
*
* <p>仅记录 userId、目标 ID、action、entityType、requestId 与时间等非敏感追踪字段,
* 不记录消息正文、手机号、邮箱、密码、JWT 等隐私。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SecurityAuditService {
private final AuditLogRepository auditLogRepository;
/**
* 记录一条拒绝型安全审计事件(独立事务提交,不受外层回滚影响)。
* 持久化失败不向上抛出,避免阻断主业务异常响应路径。
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logRejected(Long userId, String action, String entityType, Long entityId,
String details, AuditContext ctx) {
try {
String safeDetails = sanitize(details);
AuditLog auditLog = AuditLog.builder()
.userId(userId)
.action(action)
.entityType(entityType)
.entityId(entityId)
.details(safeDetails)
.ipAddress(ctx != null ? ctx.getIpAddress() : null)
.userAgent(ctx != null ? ctx.getUserAgent() : null)
.requestId(ctx != null ? ctx.getRequestId() : null)
.createdAt(LocalDateTime.now())
.build();
auditLogRepository.save(auditLog);
} catch (Exception e) {
// 安全审计持久化失败不阻断主业务异常响应
log.error("Failed to persist security audit log: action={}, userId={}, error={}",
action, userId, e.getMessage());
}
}
/**
* 脱敏:不记录可能的敏感字段值。
*/
private String sanitize(String details) {
if (details == null) return null;
return details.replaceAll("(?i)code[=:]\\s*\\S+", "code=[REDACTED]")
.replaceAll("(?i)password[=:]\\s*\\S+", "password=[REDACTED]")
.replaceAll("(?i)token[=:]\\s*\\S+", "token=[REDACTED]")
.replaceAll("(?i)secret[=:]\\s*\\S+", "secret=[REDACTED]")
.replaceAll("(?i)phone[=:]\\s*\\S+", "phone=[REDACTED]")
.replaceAll("(?i)email[=:]\\s*\\S+", "email=[REDACTED]");
}
}

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

@ -3,6 +3,10 @@ package com.mydreamjob.auth;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.deletion.AccountDeletion;
import com.mydreamjob.deletion.AccountDeletionPhoneHasher;
import com.mydreamjob.deletion.AccountDeletionRepository;
import com.mydreamjob.deletion.DeletionStatus;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -22,6 +26,8 @@ public class AuthService {
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final SmsVerificationService smsVerificationService; private final SmsVerificationService smsVerificationService;
private final AuditService auditService; private final AuditService auditService;
private final AccountDeletionRepository accountDeletionRepository;
private final AccountDeletionPhoneHasher accountDeletionPhoneHasher;
/** /**
* Mock 登录:验证短信验证码后自动创建/登录用户 * Mock 登录:验证短信验证码后自动创建/登录用户
@ -32,6 +38,13 @@ public class AuthService {
// 验证验证码(传入 purpose,确保验证码用途匹配) // 验证验证码(传入 purpose,确保验证码用途匹配)
smsVerificationService.verifyCode(phone, code, purpose); smsVerificationService.verifyCode(phone, code, purpose);
// 原手机号禁止重新注册:已注销账号的手机号墓碑(HMAC 摘要)命中则拒绝,
// 且不得自动创建新用户(不把原手机号明文写入注销表或审计)。
String phoneHash = accountDeletionPhoneHasher.hash(phone);
if (accountDeletionRepository.findByDeletedPhoneHash(phoneHash).isPresent()) {
throw new BusinessException(ErrorCode.ACCOUNT_DELETED);
}
// 查找或创建用户 // 查找或创建用户
Optional<User> userOpt = userRepository.findByPhone(phone); Optional<User> userOpt = userRepository.findByPhone(phone);
User user; User user;
@ -49,6 +62,12 @@ public class AuthService {
user = userOpt.get(); user = userOpt.get();
} }
// 已注销(COMPLETED)用户禁止登录
java.util.Optional<AccountDeletion> deletion = accountDeletionRepository.findByUserId(user.getId());
if (deletion.isPresent() && deletion.get().getStatus() == DeletionStatus.COMPLETED) {
throw new BusinessException(ErrorCode.ACCOUNT_DELETED);
}
// 生成 token // 生成 token
String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getPhone(), Map.of("role", user.getRole().name())); String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getPhone(), Map.of("role", user.getRole().name()));
String refreshToken = jwtTokenProvider.generateRefreshToken(user.getId(), user.getPhone()); String refreshToken = jwtTokenProvider.generateRefreshToken(user.getId(), user.getPhone());

3
mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java

@ -11,4 +11,7 @@ public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email); Optional<User> findByEmail(String email);
boolean existsByPhone(String phone); boolean existsByPhone(String phone);
boolean existsByEmail(String email); boolean existsByEmail(String email);
/** 统计主角色为给定角色且处于启用状态的用户数(用于最后一个有效 ADMIN 保护)。 */
long countByRoleAndActiveTrue(User.Role role);
} }

74
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateDimensionScore.java

@ -0,0 +1,74 @@
package com.mydreamjob.candidatescore;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 候选人维度评分快照(评分基础设施写入,仅由候选人生成评分引擎 upsert)
* <p>唯一约束 (student_user_id, dimension, period),同周期覆盖、version 递增。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "candidate_dimension_scores",
uniqueConstraints = @UniqueConstraint(name = "uk_candidate_dim_scores",
columnNames = {"student_user_id", "dimension", "period"}))
@EntityListeners(AuditingEntityListener.class)
public class CandidateDimensionScore {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "student_user_id", nullable = false)
private Long studentUserId;
@Column(name = "dimension", nullable = false, length = 20)
private String dimension;
/** yyyy-MM */
@Column(name = "period", nullable = false, length = 7)
private String period;
@Column(name = "score", precision = 5, scale = 2)
private BigDecimal score;
@Column(name = "status", nullable = false, length = 30)
private String status;
@Column(name = "sub_scores_json", columnDefinition = "text")
private String subScoresJson;
@Column(name = "weight", precision = 6, scale = 2, nullable = false)
@Builder.Default
private BigDecimal weight = new BigDecimal("12.50");
@Column(name = "rules_version", nullable = false, length = 40)
private String rulesVersion;
@Column(name = "calculated_at", nullable = false)
private LocalDateTime calculatedAt;
@Column(name = "version", nullable = false)
@Builder.Default
private Long version = 0L;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

34
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateDimensionScoreRepository.java

@ -0,0 +1,34 @@
package com.mydreamjob.candidatescore;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface CandidateDimensionScoreRepository extends JpaRepository<CandidateDimensionScore, Long> {
@Query("SELECT s FROM CandidateDimensionScore s WHERE s.studentUserId = :studentUserId AND s.period = :period")
List<CandidateDimensionScore> findByStudentUserIdAndPeriod(@Param("studentUserId") Long studentUserId,
@Param("period") String period);
@Query("SELECT s FROM CandidateDimensionScore s WHERE s.studentUserId = :studentUserId " +
"AND s.period >= :startPeriod ORDER BY s.period ASC")
List<CandidateDimensionScore> findTrendFrom(@Param("studentUserId") Long studentUserId,
@Param("startPeriod") String startPeriod);
@Query("SELECT s FROM CandidateDimensionScore s WHERE s.studentUserId = :studentUserId " +
"ORDER BY s.period ASC, s.dimension ASC")
List<CandidateDimensionScore> findByStudentUserId(@Param("studentUserId") Long studentUserId);
/**
* 同岗位 rank 用:批量读取一批学生的快照(period &lt;= asOf),Java 侧再按学生取最新周期,
* 避免 N+1;period 为 'yyyy-MM',字典序即时间序。
*/
@Query("SELECT s FROM CandidateDimensionScore s WHERE s.studentUserId IN :studentUserIds " +
"AND s.period <= :asOf")
List<CandidateDimensionScore> findUpToPeriod(@Param("studentUserIds") java.util.Collection<Long> studentUserIds,
@Param("asOf") String asOf);
}

2
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardService.java

@ -57,7 +57,7 @@ public class CandidateScoreCardService {
// 3. 标签聚合(使用 JdbcTemplate 避免 H2 JSON 类型兼容问题) // 3. 标签聚合(使用 JdbcTemplate 避免 H2 JSON 类型兼容问题)
List<String> tagJsons = jdbcTemplate.queryForList( List<String> tagJsons = jdbcTemplate.queryForList(
"SELECT CAST(cf.tags AS VARCHAR) FROM candidate_feedback cf WHERE cf.student_user_id = ?", "SELECT CAST(cf.tags AS CHAR) FROM candidate_feedback cf WHERE cf.student_user_id = ?",
String.class, studentUserId); String.class, studentUserId);
Map<String, Integer> tagCounts = aggregateTags(tagJsons); Map<String, Integer> tagCounts = aggregateTags(tagJsons);
List<CandidateFeedbackSummary.TagCount> tagList = tagCounts.entrySet().stream() List<CandidateFeedbackSummary.TagCount> tagList = tagCounts.entrySet().stream()

506
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java

@ -0,0 +1,506 @@
package com.mydreamjob.candidatescore;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.preference.StudentPreference;
import com.mydreamjob.preference.StudentPreferenceRepository;
import com.mydreamjob.profile.StudentProfile;
import com.mydreamjob.profile.StudentProfileRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.Query;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
/**
* 求职者评分确定性计算器(评分治理与评分引擎共享)
* <p>承载 8 维评分计算、数据采集、快照批量 upsert 与单维度重算;纯确定性规则,无 AI。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CandidateScoringCalculator {
public static final String RULES_VERSION = "candidate-scoring-v0.1";
public static final BigDecimal DEFAULT_WEIGHT = new BigDecimal("12.50");
public static boolean isValidDimension(String dimension) {
return DIMENSIONS.contains(dimension);
}
/**
* 由快照推导候选人综合分:只统计 OK/ESTIMATED 维度(引擎真实加权归一化公式)。
* 与 /profile/me/candidate-scoring 的 overallScore 完全同源(均等权重 12.50,参与维度重新归一化 = 算术平均)。
* 无有效维度 → null。
*/
public BigDecimal overallScoreFromSnapshots(List<CandidateDimensionScore> snapshots) {
if (snapshots == null || snapshots.isEmpty()) return null;
List<BigDecimal> computable = snapshots.stream()
.filter(s -> "OK".equals(s.getStatus()) || "ESTIMATED".equals(s.getStatus()))
.map(CandidateDimensionScore::getScore)
.filter(Objects::nonNull)
.toList();
if (computable.isEmpty()) return null;
BigDecimal sum = computable.stream().reduce(ZERO, BigDecimal::add);
return sum.divide(BigDecimal.valueOf(computable.size()), 2, RoundingMode.HALF_UP);
}
private static final BigDecimal ZERO = BigDecimal.ZERO;
private static final BigDecimal HUNDRED = new BigDecimal("100");
private static final Set<String> DIMENSIONS = Set.of(
"EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE",
"STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY");
private static final ObjectMapper MAPPER = new ObjectMapper();
// 内置学校档次名单(v0.1 硬编码,非数据库表)
private static final Set<String> C985 = new HashSet<>(List.of(
"北京大学", "清华大学", "中国人民大学", "北京航空航天大学", "北京理工大学",
"中国农业大学", "北京师范大学", "中央民族大学", "南开大学", "天津大学",
"大连理工大学", "吉林大学", "哈尔滨工业大学", "复旦大学", "同济大学",
"上海交通大学", "华东师范大学", "南京大学", "东南大学", "浙江大学",
"中国科学技术大学", "厦门大学", "山东大学", "中国海洋大学", "武汉大学",
"华中科技大学", "湖南大学", "中南大学", "中山大学", "华南理工大学",
"四川大学", "重庆大学", "电子科技大学", "西安交通大学", "西北工业大学",
"兰州大学", "东北大学", "西北农林科技大学"));
private static final Set<String> C211 = new HashSet<>(List.of(
"北京交通大学", "北京工业大学", "北京科技大学", "北京化工大学", "北京邮电大学",
"北京林业大学", "北京中医药大学", "北京外国语大学", "中国传媒大学", "中央财经大学",
"对外经济贸易大学", "中国政法大学", "华北电力大学", "天津医科大学", "河北工业大学",
"太原理工大学", "内蒙古大学", "辽宁大学", "大连海事大学", "延边大学",
"哈尔滨工程大学", "东北农业大学", "东北林业大学", "东华大学", "上海大学",
"上海财经大学", "上海外国语大学", "苏州大学", "南京航空航天大学", "南京理工大学",
"中国矿业大学", "河海大学", "江南大学", "南京农业大学", "中国药科大学",
"南京师范大学", "安徽大学", "合肥工业大学", "福州大学", "南昌大学",
"郑州大学", "武汉理工大学", "华中农业大学", "华中师范大学", "中南财经政法大学",
"湖南师范大学", "暨南大学", "华南师范大学", "广西大学", "海南大学",
"西南交通大学", "西南财经大学", "贵州大学", "云南大学", "西北大学",
"西安电子科技大学", "长安大学", "新疆大学", "石河子大学", "宁夏大学",
"青海大学", "北京科技大学"));
private final StudentProfileRepository profileRepository;
private final StudentPreferenceRepository preferenceRepository;
private final UserRepository userRepository;
private final JdbcTemplate jdbcTemplate;
@PersistenceContext
private EntityManager entityManager;
// ── 数据采集(一次性、单学生过滤,无 N+1)─────────────────────
public List<DimensionResult> computeAll(Long studentUserId) {
User user = userRepository.findById(studentUserId).orElse(null);
StudentProfile profile = profileRepository.findByUserId(studentUserId).orElse(null);
StudentPreference pref = preferenceRepository.findByStudentUserId(studentUserId).orElse(null);
AppAgg apps = queryApps(studentUserId);
AuditAgg audit = queryAudit(studentUserId);
OfferAgg offers = queryOffers(studentUserId);
FbAgg fb = queryFeedback(studentUserId);
ResumeAgg resume = queryResume(studentUserId);
long msgCount = queryMessages(studentUserId);
List<DimensionResult> list = new ArrayList<>();
list.add(computeEducation(profile));
list.add(computeInternship());
list.add(computeAbility());
list.add(computeCompliance(pref));
list.add(computeStability(user != null ? user.getCreatedAt() : null,
(int) apps.total, (int) apps.withdrawn, (int) audit.login30d));
list.add(computeGrowth((int) resume.count, resume.latestUpdate, skillCount(profile)));
list.add(computeIntegrity((int) offers.accepted, (int) offers.expired,
fb.count, fb.avg, isProfileComplete(profile, resume.count)));
list.add(computeActivity((int) apps.recent90d, (int) audit.total30d, msgCount));
return list;
}
/**
* 按当前数据确定性重算单维度并写入指定 period 的快照(评分治理回滚复用)
*/
public DimensionResult recomputeAndSnapshot(Long studentUserId, String dimension, String period) {
if (!DIMENSIONS.contains(dimension)) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension: " + dimension);
}
DimensionResult r = computeAll(studentUserId).stream()
.filter(x -> x.dimension.equals(dimension)).findFirst()
.orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension"));
upsertSnapshots(studentUserId, List.of(r), period);
return r;
}
private AppAgg queryApps(Long sid) {
return jdbcTemplate.queryForObject(
"SELECT COUNT(*), COALESCE(SUM(CASE WHEN status='WITHDRAWN' THEN 1 ELSE 0 END),0), " +
"MIN(created_at), COALESCE(SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END),0) " +
"FROM applications WHERE student_user_id = ?",
(rs, n) -> new AppAgg(rs.getLong(1), rs.getLong(2),
rs.getTimestamp(3) == null ? null : rs.getTimestamp(3).toLocalDateTime(), rs.getLong(4)),
LocalDateTime.now().minusDays(90), sid);
}
private AuditAgg queryAudit(Long sid) {
return jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END),0), " +
"COALESCE(SUM(CASE WHEN action='LOGIN' AND created_at >= ? THEN 1 ELSE 0 END),0) " +
"FROM audit_logs WHERE user_id = ?",
(rs, n) -> new AuditAgg(rs.getLong(1), rs.getLong(2)),
LocalDateTime.now().minusDays(30), LocalDateTime.now().minusDays(30), sid);
}
private OfferAgg queryOffers(Long sid) {
return jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(CASE WHEN status='ACCEPTED' THEN 1 ELSE 0 END),0), " +
"COALESCE(SUM(CASE WHEN status='EXPIRED' THEN 1 ELSE 0 END),0) " +
"FROM offers WHERE candidate_id = ?",
(rs, n) -> new OfferAgg(rs.getLong(1), rs.getLong(2)), sid);
}
private FbAgg queryFeedback(Long sid) {
return jdbcTemplate.queryForObject(
"SELECT COUNT(*), COALESCE(AVG(rating),0) FROM candidate_feedback WHERE student_user_id = ?",
(rs, n) -> new FbAgg(rs.getLong(1), rs.getDouble(2)), sid);
}
private ResumeAgg queryResume(Long sid) {
return jdbcTemplate.queryForObject(
"SELECT COUNT(*), MAX(updated_at) FROM resume_versions WHERE user_id = ? AND status='ACTIVE'",
(rs, n) -> new ResumeAgg(rs.getLong(1),
rs.getTimestamp(2) == null ? null : rs.getTimestamp(2).toLocalDateTime()), sid);
}
private long queryMessages(Long sid) {
Long c = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM messages WHERE sender_user_id = ?", Long.class, sid);
return c == null ? 0 : c;
}
// ── 八维规则(确定性)──────────────────────────────────────
public DimensionResult computeEducation(StudentProfile profile) {
DimensionResult r = new DimensionResult("EDUCATION");
if (profile == null || isBlank(profile.getEducation())) {
r.status = "INSUFFICIENT_DATA";
r.summary = "未填写学历信息";
return r;
}
BigDecimal eduLevel = switch (profile.getEducation()) {
case "博士" -> new BigDecimal("100");
case "硕士" -> new BigDecimal("85");
case "本科" -> new BigDecimal("70");
case "大专" -> new BigDecimal("55");
default -> new BigDecimal("50");
};
String school = profile.getSchool();
boolean estimated = false;
BigDecimal schoolTier;
if (isBlank(school)) {
schoolTier = new BigDecimal("50");
estimated = true;
r.reasons.add("学校未填写,按兜底档位估算");
} else if (C985.contains(school)) {
schoolTier = new BigDecimal("85");
} else if (C211.contains(school)) {
schoolTier = new BigDecimal("75");
} else if (school.endsWith("大学") || school.endsWith("学院")) {
schoolTier = new BigDecimal("65");
} else {
schoolTier = new BigDecimal("50");
estimated = true;
r.reasons.add("学校不在内置名单,按兜底档位估算");
}
r.score = eduLevel.multiply(new BigDecimal("0.55"))
.add(schoolTier.multiply(new BigDecimal("0.45")))
.setScale(2, RoundingMode.HALF_UP);
r.status = estimated ? "ESTIMATED" : "OK";
r.summary = "学历层次与学校档次的加权综合";
r.subScores.add(sub("学历层次", eduLevel, new BigDecimal("55"), profile.getEducation()));
r.subScores.add(sub("学校档次", schoolTier, new BigDecimal("45"), isBlank(school) ? "未填写" : school));
r.dataSources.add(ds("student_profiles.education/school", profile.getUpdatedAt(), "学历与学校字段"));
return r;
}
public DimensionResult computeInternship() {
DimensionResult r = new DimensionResult("INTERNSHIP");
r.status = "PENDING";
r.summary = "实习经历数据源未就绪";
r.reasons.add("结构化实习经历尚未提供");
return r;
}
public DimensionResult computeAbility() {
DimensionResult r = new DimensionResult("ABILITY");
r.status = "PENDING";
r.summary = "能力测评数据源未就绪";
r.reasons.add("能力测评(F1.2)尚未实现");
return r;
}
public DimensionResult computeCompliance(StudentPreference pref) {
DimensionResult r = new DimensionResult("COMPLIANCE");
if (pref == null) {
r.status = "INSUFFICIENT_DATA";
r.summary = "未设置求职偏好";
return r;
}
boolean salary = pref.getExpectedSalaryMin() != null || pref.getExpectedSalaryMax() != null;
boolean city = !jsonListEmpty(pref.getPreferredCities());
boolean type = !jsonListEmpty(pref.getEmploymentTypes());
boolean industry = !jsonListEmpty(pref.getPreferredIndustries());
boolean jobType = !jsonListEmpty(pref.getPreferredJobTypes());
int present = 0;
BigDecimal score = ZERO;
if (salary) { present++; score = score.add(new BigDecimal("25")); }
if (city) { present++; score = score.add(new BigDecimal("25")); }
if (type) { present++; score = score.add(new BigDecimal("20")); }
if (industry) { present++; score = score.add(new BigDecimal("15")); }
if (jobType) { present++; score = score.add(new BigDecimal("15")); }
if (present == 0) {
r.status = "INSUFFICIENT_DATA";
r.summary = "未填写任何求职偏好字段";
return r;
}
r.score = score.setScale(2, RoundingMode.HALF_UP);
r.status = present == 5 ? "OK" : "ESTIMATED";
r.summary = "求职偏好完整度(五字段加权)";
r.subScores.add(sub("薪资区间", salary ? new BigDecimal("25") : ZERO, new BigDecimal("25"), salary ? "已填" : "未填"));
r.subScores.add(sub("城市偏好", city ? new BigDecimal("25") : ZERO, new BigDecimal("25"), city ? "已填" : "未填"));
r.subScores.add(sub("用工类型", type ? new BigDecimal("20") : ZERO, new BigDecimal("20"), type ? "已填" : "未填"));
r.subScores.add(sub("行业偏好", industry ? new BigDecimal("15") : ZERO, new BigDecimal("15"), industry ? "已填" : "未填"));
r.subScores.add(sub("岗位类型", jobType ? new BigDecimal("15") : ZERO, new BigDecimal("15"), jobType ? "已填" : "未填"));
r.dataSources.add(ds("student_preferences", pref.getUpdatedAt(), "薪资/城市/用工/行业/岗位偏好"));
return r;
}
public DimensionResult computeStability(LocalDateTime accountCreatedAt, int totalApps, int withdrawnApps, int login30d) {
DimensionResult r = new DimensionResult("STABILITY");
if (totalApps == 0 && login30d == 0) {
r.status = "INSUFFICIENT_DATA";
r.summary = "无投递且无登录活动";
return r;
}
BigDecimal score = new BigDecimal("50");
long ageMonths = accountCreatedAt == null ? 0 : ChronoUnit.MONTHS.between(accountCreatedAt, LocalDateTime.now());
if (ageMonths > 12) { score = score.add(new BigDecimal("25")); r.reasons.add("账号活跃期超过 12 个月"); }
else if (ageMonths > 6) { score = score.add(new BigDecimal("15")); r.reasons.add("账号活跃期超过 6 个月"); }
if (totalApps > 0) {
double rate = withdrawnApps * 1.0 / totalApps;
if (rate < 0.2) { score = score.add(new BigDecimal("15")); r.reasons.add("撤回率低于 20%"); }
else if (rate > 0.5) { score = score.subtract(new BigDecimal("20")); r.reasons.add("撤回率高于 50%"); }
}
if (login30d > 0) { score = score.add(new BigDecimal("10")); r.reasons.add("近 30 天有登录"); }
r.score = cap(score);
r.status = "OK";
r.summary = "账号活跃期、撤回率与登录信号的稳定性评估";
r.dataSources.add(ds("users.createdAt/applications/audit_logs", LocalDateTime.now(), "账号年龄与求职行为"));
return r;
}
public DimensionResult computeGrowth(int activeResumeCount, LocalDateTime latestResumeUpdate, int skillCount) {
DimensionResult r = new DimensionResult("GROWTH");
if (activeResumeCount == 0 && skillCount == 0) {
r.status = "INSUFFICIENT_DATA";
r.summary = "无简历版本且无技能标签";
return r;
}
BigDecimal score = new BigDecimal("40");
if (activeResumeCount >= 2) { score = score.add(new BigDecimal("15")); r.reasons.add("简历版本数 >= 2"); }
if (latestResumeUpdate != null && latestResumeUpdate.isAfter(LocalDateTime.now().minusDays(90))) {
score = score.add(new BigDecimal("15")); r.reasons.add("近 90 天内更新过简历");
}
if (skillCount >= 5) { score = score.add(new BigDecimal("15")); r.reasons.add("技能标签 >= 5"); }
r.score = cap(score);
r.status = "OK";
r.summary = "简历迭代、更新频率与技能数量的成长信号";
r.dataSources.add(ds("resume_versions/student_profiles.skillsJson", LocalDateTime.now(), "简历与技能"));
return r;
}
public DimensionResult computeIntegrity(int acceptedOffers, int expiredOffers, long feedbackCount,
double avgRating, boolean profileComplete) {
DimensionResult r = new DimensionResult("INTEGRITY");
if (acceptedOffers == 0 && expiredOffers == 0 && feedbackCount == 0) {
r.status = "INSUFFICIENT_DATA";
r.summary = "无 Offer 历史且无候选人反馈";
return r;
}
BigDecimal score = new BigDecimal("60");
if (acceptedOffers > 0) { score = score.add(new BigDecimal(Math.min(acceptedOffers, 2) * 15)); r.reasons.add("接受 Offer(守约信号)"); }
if (expiredOffers > 0) { score = score.subtract(new BigDecimal(Math.min(expiredOffers, 2) * 10)); r.reasons.add("Offer 过期未响应"); }
if (feedbackCount >= 3 && avgRating >= 4) { score = score.add(new BigDecimal("10")); r.reasons.add("候选人反馈质量良好"); }
else if (feedbackCount > 0 && avgRating < 3) { score = score.subtract(new BigDecimal("10")); r.reasons.add("候选人反馈评分偏低"); }
if (profileComplete) { score = score.add(new BigDecimal("10")); r.reasons.add("资料完整且一致"); }
r.score = cap(score);
r.status = "OK";
r.summary = "Offer 守约、反馈质量与资料完整度";
r.dataSources.add(ds("offers/offer_status_history/candidate_feedback/student_profiles/resume_versions",
LocalDateTime.now(), "守约与资料信号"));
return r;
}
public DimensionResult computeActivity(int apps90d, int audit30d, long msgCount) {
DimensionResult r = new DimensionResult("ACTIVITY");
if (apps90d == 0 && audit30d == 0 && msgCount == 0) {
r.status = "INSUFFICIENT_DATA";
r.summary = "近期无投递、登录或沟通活动";
return r;
}
BigDecimal score = ZERO;
if (apps90d >= 6) { score = score.add(new BigDecimal("50")); r.reasons.add("近 90 天投递 >= 6"); }
else if (apps90d >= 3) { score = score.add(new BigDecimal("40")); r.reasons.add("近 90 天投递 >= 3"); }
else if (apps90d >= 1) { score = score.add(new BigDecimal("20")); r.reasons.add("近 90 天有投递"); }
if (audit30d >= 3) { score = score.add(new BigDecimal("30")); r.reasons.add("近 30 天活跃操作 >= 3"); }
if (msgCount > 0) { score = score.add(new BigDecimal("20")); r.reasons.add("有沟通消息"); }
r.score = cap(score);
r.status = "OK";
r.summary = "投递、登录与沟通活跃度";
r.dataSources.add(ds("applications/audit_logs/messages", LocalDateTime.now(), "活跃行为"));
return r;
}
// ── 快照批量 upsert(原生 MySQL)────────────────────────────
public void upsertSnapshots(Long studentUserId, List<DimensionResult> results, String period) {
if (results.isEmpty()) return;
StringBuilder sql = new StringBuilder(
"INSERT INTO candidate_dimension_scores (student_user_id, dimension, period, score, status, " +
"sub_scores_json, weight, rules_version, calculated_at, version, created_at, updated_at) VALUES ");
List<Object> params = new ArrayList<>();
for (int i = 0; i < results.size(); i++) {
if (i > 0) sql.append(",");
sql.append("(?,?,?,?,?,?,?,?,NOW(),0,NOW(),NOW())");
DimensionResult r = results.get(i);
params.add(studentUserId);
params.add(r.dimension);
params.add(period);
params.add(r.score);
params.add(r.status);
params.add(toJson(r.subScores));
params.add(DEFAULT_WEIGHT);
params.add(RULES_VERSION);
}
sql.append(" ON DUPLICATE KEY UPDATE score=VALUES(score), status=VALUES(status), " +
"sub_scores_json=VALUES(sub_scores_json), weight=VALUES(weight), " +
"rules_version=VALUES(rules_version), calculated_at=VALUES(calculated_at), " +
"version=version+1, updated_at=NOW()");
Query q = entityManager.createNativeQuery(sql.toString());
for (int i = 0; i < params.size(); i++) {
q.setParameter(i + 1, params.get(i));
}
q.executeUpdate();
}
// ── 工具 ────────────────────────────────────────────────────
public String yearMonth(LocalDate d) {
return String.format("%04d-%02d", d.getYear(), d.getMonthValue());
}
private boolean isBlank(String s) {
return s == null || s.isBlank();
}
private boolean jsonListEmpty(String json) {
if (json == null || json.isBlank()) return true;
try {
List<String> list = MAPPER.readValue(json, new TypeReference<List<String>>() {});
return list.isEmpty();
} catch (Exception e) {
return true;
}
}
private int skillCount(StudentProfile profile) {
if (profile == null || isBlank(profile.getSkillsJson())) return 0;
try {
List<String> list = MAPPER.readValue(profile.getSkillsJson(), new TypeReference<List<String>>() {});
return (int) list.stream().filter(s -> s != null && !s.isBlank()).count();
} catch (Exception e) {
return 0;
}
}
private boolean isProfileComplete(StudentProfile profile, long activeResumeCount) {
if (profile == null || activeResumeCount == 0) return false;
return !isBlank(profile.getRealName()) && !isBlank(profile.getEducation()) && !isBlank(profile.getSchool());
}
private String toJson(List<DimensionScoreDetailResponse.SubScore> subs) {
if (subs == null || subs.isEmpty()) return null;
try {
return MAPPER.writeValueAsString(subs);
} catch (Exception e) {
return null;
}
}
private BigDecimal cap(BigDecimal v) {
return v.max(ZERO).min(HUNDRED).setScale(2, RoundingMode.HALF_UP);
}
private DimensionScoreDetailResponse.SubScore sub(String name, BigDecimal score, BigDecimal weight, String desc) {
return DimensionScoreDetailResponse.SubScore.builder()
.name(name).score(score).weight(weight).description(desc).build();
}
private DimensionScoreDetailResponse.DataSourceItem ds(String source, LocalDateTime time, String note) {
return DimensionScoreDetailResponse.DataSourceItem.builder()
.source(source).time(time).note(note).build();
}
// ── 结果与聚合载体 ──────────────────────────────────────────
public static class DimensionResult {
public String dimension;
public BigDecimal score;
public String status;
public List<DimensionScoreDetailResponse.SubScore> subScores = new ArrayList<>();
public List<String> reasons = new ArrayList<>();
public List<DimensionScoreDetailResponse.DataSourceItem> dataSources = new ArrayList<>();
public String summary;
public DimensionResult(String dimension) {
this.dimension = dimension;
}
}
public static class AppAgg {
public long total; public long withdrawn; public LocalDateTime earliest; public long recent90d;
public AppAgg(long total, long withdrawn, LocalDateTime earliest, long recent90d) {
this.total = total; this.withdrawn = withdrawn; this.earliest = earliest; this.recent90d = recent90d;
}
}
public static class AuditAgg {
public long total30d; public long login30d;
public AuditAgg(long total30d, long login30d) { this.total30d = total30d; this.login30d = login30d; }
}
public static class OfferAgg {
public long accepted; public long expired;
public OfferAgg(long accepted, long expired) { this.accepted = accepted; this.expired = expired; }
}
public static class FbAgg {
public long count; public double avg;
public FbAgg(long count, double avg) { this.count = count; this.avg = avg; }
}
public static class ResumeAgg {
public long count; public LocalDateTime latestUpdate;
public ResumeAgg(long count, LocalDateTime latestUpdate) { this.count = count; this.latestUpdate = latestUpdate; }
}
}

81
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringController.java

@ -0,0 +1,81 @@
package com.mydreamjob.candidatescore;
import com.mydreamjob.candidatescore.dto.CandidateScoringOverviewResponse;
import com.mydreamjob.candidatescore.dto.CandidateScoringTrendResponse;
import com.mydreamjob.candidatescore.dto.CompanyCandidateScoringResponse;
import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.CompanyService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
/**
* 求职者评分体系(Candidate Scoring Engine)
* 学生自查:/profile/me/candidate-scoring*;企业只读:/company/candidates/{applicationId}/scoring
*/
@Tag(name = "求职者评分体系", description = "8 维候选人生成评分、综合分、趋势与企业只读评分卡")
@RestController
@RequiredArgsConstructor
public class CandidateScoringController {
private final CandidateScoringEngineService scoringEngineService;
private final CompanyService companyService;
@Operation(summary = "综合评分卡", description = "仅 STUDENT;返回本人 8 维评分、综合分、权重与状态")
@GetMapping("/profile/me/candidate-scoring")
public ResponseEntity<ApiResponse<CandidateScoringOverviewResponse>> getOverview(HttpServletRequest request) {
Long userId = resolveUserId();
companyService.assertStudentRole(userId);
CandidateScoringOverviewResponse data = scoringEngineService.getOverview(userId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "单维度明细", description = "仅 STUDENT;返回某维度分项、归因与数据源;非法 dimension 返回 400")
@GetMapping("/profile/me/candidate-scoring/dimensions/{dimension}")
public ResponseEntity<ApiResponse<DimensionScoreDetailResponse>> getDimensionDetail(
@PathVariable String dimension, HttpServletRequest request) {
Long userId = resolveUserId();
companyService.assertStudentRole(userId);
DimensionScoreDetailResponse data = scoringEngineService.getDimensionDetail(userId, dimension);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "评分趋势", description = "仅 STUDENT;基于快照按月返回综合分与维度分(months 1-24)")
@GetMapping("/profile/me/candidate-scoring/trend")
public ResponseEntity<ApiResponse<CandidateScoringTrendResponse>> getTrend(
@RequestParam(defaultValue = "6") int months, HttpServletRequest request) {
Long userId = resolveUserId();
companyService.assertStudentRole(userId);
CandidateScoringTrendResponse data = scoringEngineService.getTrend(userId, months);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "企业候选人评分卡", description = "仅岗位所属企业;只读,仅返回 applicationId/overallScore/status/dimensions/generatedAt/rulesVersion,不泄露候选人敏感信息")
@GetMapping("/company/candidates/{applicationId}/scoring")
public ResponseEntity<ApiResponse<CompanyCandidateScoringResponse>> getCompanyScoring(
@PathVariable Long applicationId, HttpServletRequest request) {
Long userId = resolveUserId();
CompanyCandidateScoringResponse data = scoringEngineService.getCompanyScoring(userId, applicationId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
private Long resolveUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
return Long.valueOf(auth.getName());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

171
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java

@ -0,0 +1,171 @@
package com.mydreamjob.candidatescore;
import com.mydreamjob.application.Application;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.candidatescore.dto.*;
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.JobService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
/**
* 求职者评分体系(Candidate Scoring Engine)v0.1
* <p>公开 4 个接口不变;评分计算委托 {@link CandidateScoringCalculator}(评分治理复用同一计算器)。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CandidateScoringEngineService {
private static final BigDecimal ZERO = BigDecimal.ZERO;
private final CandidateScoringCalculator calculator;
private final CandidateDimensionScoreRepository snapshotRepository;
private final CompanyRepository companyRepository;
private final ApplicationRepository applicationRepository;
private final JobService jobService;
// ── 综合评分卡(学生自查)────────────────────────────────────
@Transactional
public CandidateScoringOverviewResponse getOverview(Long studentUserId) {
List<CandidateScoringCalculator.DimensionResult> results = calculator.computeAll(studentUserId);
calculator.upsertSnapshots(studentUserId, results, yearMonth(LocalDate.now()));
return buildOverview(studentUserId, results);
}
// ── 单维度明细 ───────────────────────────────────────────────
@Transactional
public DimensionScoreDetailResponse getDimensionDetail(Long studentUserId, String dimension) {
List<CandidateScoringCalculator.DimensionResult> results = calculator.computeAll(studentUserId);
CandidateScoringCalculator.DimensionResult r = results.stream()
.filter(x -> x.dimension.equals(dimension))
.findFirst().orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension"));
calculator.upsertSnapshots(studentUserId, List.of(r), yearMonth(LocalDate.now()));
return toDetail(r);
}
// ── 趋势(只读快照,不写)────────────────────────────────────
@Transactional(readOnly = true)
public CandidateScoringTrendResponse getTrend(Long studentUserId, int months) {
if (months < 1 || months > 24) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "months must be between 1 and 24");
}
LocalDate startDate = LocalDate.now().withDayOfMonth(1).minusMonths(months - 1);
String startPeriod = yearMonth(startDate);
List<CandidateDimensionScore> snaps =
snapshotRepository.findTrendFrom(studentUserId, startPeriod);
Map<String, List<CandidateDimensionScore>> byPeriod = snaps.stream()
.collect(java.util.stream.Collectors.groupingBy(CandidateDimensionScore::getPeriod,
TreeMap::new, java.util.stream.Collectors.toList()));
List<CandidateScoringTrendResponse.TrendItem> items = byPeriod.entrySet().stream().map(e -> {
Map<String, BigDecimal> dims = new LinkedHashMap<>();
List<BigDecimal> computable = new ArrayList<>();
String rulesVersion = e.getValue().isEmpty()
? CandidateScoringCalculator.RULES_VERSION : e.getValue().get(0).getRulesVersion();
for (CandidateDimensionScore s : e.getValue()) {
if ("OK".equals(s.getStatus()) || "ESTIMATED".equals(s.getStatus())) {
dims.put(s.getDimension(), s.getScore());
computable.add(s.getScore());
}
}
BigDecimal overall = computable.isEmpty() ? null : mean(computable);
return CandidateScoringTrendResponse.TrendItem.builder()
.period(e.getKey()).overallScore(overall).rulesVersion(rulesVersion).dimensions(dims).build();
}).collect(java.util.stream.Collectors.toList());
return CandidateScoringTrendResponse.builder()
.candidateId(studentUserId).months(months).items(items).build();
}
// ── 企业只读评分卡 ───────────────────────────────────────────
@Transactional
public CompanyCandidateScoringResponse getCompanyScoring(Long companyUserId, Long applicationId) {
Company company = companyRepository.findByOwnerUserId(companyUserId)
.orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN));
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId());
if (!company.getId().equals(job.getCompanyId())) {
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
}
List<CandidateScoringCalculator.DimensionResult> results = calculator.computeAll(app.getStudentUserId());
calculator.upsertSnapshots(app.getStudentUserId(), results, yearMonth(LocalDate.now()));
List<CandidateScoringCalculator.DimensionResult> computable = results.stream()
.filter(r -> "OK".equals(r.status) || "ESTIMATED".equals(r.status))
.toList();
BigDecimal overall = computable.isEmpty() ? null
: mean(computable.stream().map(r -> r.score).toList());
String status = overviewStatus(results);
List<CompanyCandidateScoringResponse.CompanyDimensionItem> dims = results.stream()
.map(r -> CompanyCandidateScoringResponse.CompanyDimensionItem.builder()
.dimension(r.dimension).score(r.score).status(r.status).build())
.toList();
return CompanyCandidateScoringResponse.builder()
.applicationId(applicationId).overallScore(overall).status(status)
.dimensions(dims).generatedAt(Instant.now())
.rulesVersion(CandidateScoringCalculator.RULES_VERSION).build();
}
// ── 组装 ────────────────────────────────────────────────────
private CandidateScoringOverviewResponse buildOverview(Long studentUserId,
List<CandidateScoringCalculator.DimensionResult> results) {
List<CandidateScoringCalculator.DimensionResult> computable = results.stream()
.filter(r -> "OK".equals(r.status) || "ESTIMATED".equals(r.status)).toList();
BigDecimal overall = computable.isEmpty() ? null
: mean(computable.stream().map(r -> r.score).toList());
Map<String, BigDecimal> weights = new LinkedHashMap<>();
List<CandidateScoringOverviewResponse.DimensionItem> items = new ArrayList<>();
for (CandidateScoringCalculator.DimensionResult r : results) {
weights.put(r.dimension, CandidateScoringCalculator.DEFAULT_WEIGHT);
items.add(CandidateScoringOverviewResponse.DimensionItem.builder()
.dimension(r.dimension).score(r.score).status(r.status)
.weight(CandidateScoringCalculator.DEFAULT_WEIGHT).summary(r.summary).build());
}
return CandidateScoringOverviewResponse.builder()
.candidateId(studentUserId).status(overviewStatus(results)).overallScore(overall)
.weights(weights).dimensions(items).rulesVersion(CandidateScoringCalculator.RULES_VERSION)
.generatedAt(Instant.now()).build();
}
private DimensionScoreDetailResponse toDetail(CandidateScoringCalculator.DimensionResult r) {
return DimensionScoreDetailResponse.builder()
.dimension(r.dimension).score(r.score).status(r.status)
.subScores(r.subScores).reasons(r.reasons).dataSources(r.dataSources)
.updatedAt(LocalDateTime.now()).rulesVersion(CandidateScoringCalculator.RULES_VERSION).build();
}
private String overviewStatus(List<CandidateScoringCalculator.DimensionResult> results) {
long computable = results.stream()
.filter(x -> "OK".equals(x.status) || "ESTIMATED".equals(x.status)).count();
if (computable == results.size()) return "OK";
if (computable > 0) return "PARTIAL";
boolean allPending = results.stream().allMatch(x -> "PENDING".equals(x.status));
return allPending ? "PENDING" : "INSUFFICIENT_DATA";
}
private BigDecimal mean(List<BigDecimal> scores) {
BigDecimal sum = scores.stream().reduce(ZERO, BigDecimal::add);
return sum.divide(BigDecimal.valueOf(scores.size()), 2, RoundingMode.HALF_UP);
}
private String yearMonth(LocalDate d) {
return String.format("%04d-%02d", d.getYear(), d.getMonthValue());
}
}

102
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardController.java

@ -0,0 +1,102 @@
package com.mydreamjob.candidatescore.dashboard;
import com.mydreamjob.candidatescore.dashboard.dto.*;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
/**
* 企业评分运营看板:概览、岗位评分排名、候选人评分排名、评分趋势、全局概览
*/
@Tag(name = "评分运营看板", description = "企业评分运营看板、评分排名与评分趋势")
@RestController
@RequiredArgsConstructor
public class CompanyScoringDashboardController {
private final CompanyScoringDashboardService dashboardService;
@Operation(summary = "企业评分运营概览", description = "仅 COMPANY;只返回本企业数据")
@GetMapping("/company/scoring/dashboard/overview")
public ResponseEntity<ApiResponse<DashboardOverviewResponse>> getOverview(HttpServletRequest request) {
Long userId = resolveUserId();
return ResponseEntity.ok(ApiResponse.ok(
dashboardService.getOverview(userId), getRequestId(request)));
}
@Operation(summary = "岗位评分排名", description = "仅 COMPANY;avgRating DESC, jobId DESC;审核通过评价")
@GetMapping("/company/scoring/dashboard/job-rankings")
public ResponseEntity<ApiResponse<JobRankingListResponse>> getJobRankings(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId();
return ResponseEntity.ok(ApiResponse.ok(
dashboardService.getJobRankings(userId, page, size), getRequestId(request)));
}
@Operation(summary = "单岗位评分排名", description = "仅 COMPANY;跨企业 403/8004")
@GetMapping("/company/scoring/dashboard/job-rankings/{jobId}")
public ResponseEntity<ApiResponse<JobRankingItem>> getJobRanking(
@PathVariable Long jobId, HttpServletRequest request) {
Long userId = resolveUserId();
return ResponseEntity.ok(ApiResponse.ok(
dashboardService.getJobRanking(userId, jobId), getRequestId(request)));
}
@Operation(summary = "候选人评分排名", description = "仅 COMPANY;按学生去重;overallScore DESC, applicationId DESC")
@GetMapping("/company/scoring/dashboard/candidate-rankings")
public ResponseEntity<ApiResponse<CandidateRankingListResponse>> getCandidateRankings(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId();
return ResponseEntity.ok(ApiResponse.ok(
dashboardService.getCandidateRankings(userId, page, size), getRequestId(request)));
}
@Operation(summary = "单候选人评分排名明细", description = "仅 COMPANY;跨企业 403/8004")
@GetMapping("/company/scoring/dashboard/candidate-rankings/{applicationId}")
public ResponseEntity<ApiResponse<CandidateRankingDetailResponse>> getCandidateRanking(
@PathVariable Long applicationId, HttpServletRequest request) {
Long userId = resolveUserId();
return ResponseEntity.ok(ApiResponse.ok(
dashboardService.getCandidateRanking(userId, applicationId), getRequestId(request)));
}
@Operation(summary = "企业评分趋势", description = "仅 COMPANY;months 1–24")
@GetMapping("/company/scoring/dashboard/trend")
public ResponseEntity<ApiResponse<ScoreTrendResponse>> getTrend(
@RequestParam(defaultValue = "6") int months, HttpServletRequest request) {
Long userId = resolveUserId();
return ResponseEntity.ok(ApiResponse.ok(
dashboardService.getTrend(userId, months), getRequestId(request)));
}
@Operation(summary = "全局运营概览", description = "仅 ADMIN")
@GetMapping("/admin/scoring/dashboard/overview")
public ResponseEntity<ApiResponse<AdminDashboardOverviewResponse>> getAdminOverview(HttpServletRequest request) {
Long userId = resolveUserId();
return ResponseEntity.ok(ApiResponse.ok(
dashboardService.getAdminOverview(userId), getRequestId(request)));
}
private Long resolveUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
return Long.valueOf(auth.getName());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

530
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java

@ -0,0 +1,530 @@
package com.mydreamjob.candidatescore.dashboard;
import com.mydreamjob.application.Application;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScore;
import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import com.mydreamjob.candidatescore.dashboard.dto.*;
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.JobService;
import com.mydreamjob.profile.StudentProfile;
import com.mydreamjob.profile.StudentProfileRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 企业评分运营看板:概览、岗位评分排名、候选人评分排名、评分趋势、全局概览。
* <p>纯只读聚合;候选人综合分复用 CandidateScoringCalculator.overallScoreFromSnapshots;
* 岗位评价过滤与 JobReviewService.getReviewSummary 口径一致(PUBLISHED + AUTO_APPROVED/APPROVED)。
* 统计周期统一为 asOfPeriod(当前月份),每候选人读取 &lt;= asOfPeriod 的最新周期。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CompanyScoringDashboardService {
private static final Set<String> EXCLUDED_APP_STATUS = Set.of("WITHDRAWN", "REJECTED");
private static final Set<String> PUBLIC_MODERATION = Set.of("AUTO_APPROVED", "APPROVED");
private static final String REVIEW_FILTER =
"jr.status='PUBLISHED' AND jr.moderation_status IN ('AUTO_APPROVED','APPROVED')";
private final CompanyRepository companyRepository;
private final ApplicationRepository applicationRepository;
private final JobService jobService;
private final StudentProfileRepository studentProfileRepository;
private final UserRepository userRepository;
private final CandidateScoringCalculator calculator;
private final JdbcTemplate jdbcTemplate;
// ── 概览 ────────────────────────────────────────────────────
@Transactional(readOnly = true)
public DashboardOverviewResponse getOverview(Long companyUserId) {
Company company = requireCompany(companyUserId);
Long cid = company.getId();
Object[] jobAgg = jdbcTemplate.queryForObject(
"SELECT COUNT(*), COALESCE(SUM(CASE WHEN status='PUBLISHED' THEN 1 ELSE 0 END),0) " +
"FROM jobs WHERE company_id=?",
(rs, n) -> new Object[]{rs.getLong(1), rs.getLong(2)}, cid);
int totalJobs = ((Number) jobAgg[0]).intValue();
int publishedJobs = ((Number) jobAgg[1]).intValue();
BigDecimal avgJobScore = jdbcTemplate.queryForObject(
"SELECT AVG(jr.rating) FROM job_reviews jr JOIN jobs j ON jr.job_id=j.id " +
"WHERE j.company_id=? AND " + REVIEW_FILTER,
(rs, n) -> norm(rs.getBigDecimal(1)), cid);
Map<Integer, Integer> dist = new LinkedHashMap<>();
for (int i = 1; i <= 5; i++) dist.put(i, 0);
jdbcTemplate.query(
"SELECT jr.rating, COUNT(*) FROM job_reviews jr JOIN jobs j ON jr.job_id=j.id " +
"WHERE j.company_id=? AND " + REVIEW_FILTER + " GROUP BY jr.rating",
(rs, n) -> new Object[]{rs.getInt(1), rs.getLong(2)}, cid)
.forEach(r -> dist.put(((Number) r[0]).intValue(), ((Number) r[1]).intValue()));
List<CandidateWithScore> candidates = loadCandidateScores(cid, asOfPeriod());
int totalCandidates = candidates.size();
BigDecimal avgCandidateScore = mean(candidates.stream().map(c -> c.overallScore)
.filter(Objects::nonNull).collect(Collectors.toList()));
return DashboardOverviewResponse.builder().companyId(cid)
.totalJobs(totalJobs).publishedJobs(publishedJobs)
.avgJobScore(avgJobScore)
.totalCandidates(totalCandidates).avgCandidateScore(avgCandidateScore)
.scoreDistribution(dist).updatedAt(LocalDateTime.now()).build();
}
// ── 岗位评分排名 ────────────────────────────────────────────
@Transactional(readOnly = true)
public JobRankingListResponse getJobRankings(Long companyUserId, int page, int size) {
Company company = requireCompany(companyUserId);
validatePage(page, size);
Long cid = company.getId();
List<Object[]> rows = jdbcTemplate.query(
"SELECT j.id, j.title, j.city, j.employment_type, " +
"AVG(jr.rating), COUNT(jr.id) " +
"FROM jobs j LEFT JOIN job_reviews jr ON jr.job_id=j.id AND " + REVIEW_FILTER + " " +
"WHERE j.company_id=? GROUP BY j.id, j.title, j.city, j.employment_type " +
"ORDER BY AVG(jr.rating) DESC, j.id DESC",
(rs, n) -> new Object[]{rs.getLong(1), rs.getString(2), rs.getString(3), rs.getString(4),
norm(rs.getBigDecimal(5)), rs.getLong(6)}, cid);
List<Long> jobIds = rows.stream().map(r -> (Long) r[0]).collect(Collectors.toList());
Map<Long, Map<String, BigDecimal>> dimAvg = loadDimensionAverages(jobIds);
int cohort = rows.size();
List<JobRankingItem> all = new ArrayList<>();
for (int i = 0; i < rows.size(); i++) {
Object[] r = rows.get(i);
all.add(JobRankingItem.builder()
.jobId((Long) r[0]).title((String) r[1]).city((String) r[2]).employmentType((String) r[3])
.avgRating((BigDecimal) r[4]).reviewCount(((Number) r[5]).intValue())
.rank(i + 1).cohortSize(cohort)
.dimensionAverages(dimAvg.getOrDefault((Long) r[0], Map.of())).build());
}
return slice(all, page, size);
}
@Transactional(readOnly = true)
public JobRankingItem getJobRanking(Long companyUserId, Long jobId) {
Company company = requireCompany(companyUserId);
Job job = jobService.getJob(jobId);
if (!company.getId().equals(job.getCompanyId())) {
throw new BusinessException(ErrorCode.JOB_OWNER_MISMATCH);
}
Object[] row = jdbcTemplate.queryForObject(
"SELECT j.id, j.title, j.city, j.employment_type, AVG(jr.rating), COUNT(jr.id) " +
"FROM jobs j LEFT JOIN job_reviews jr ON jr.job_id=j.id AND " + REVIEW_FILTER + " " +
"WHERE j.id=? GROUP BY j.id, j.title, j.city, j.employment_type",
(rs, n) -> new Object[]{rs.getLong(1), rs.getString(2), rs.getString(3), rs.getString(4),
norm(rs.getBigDecimal(5)), rs.getLong(6)}, jobId);
Long cohort = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM jobs WHERE company_id=?",
(rs, n) -> rs.getLong(1), company.getId());
// 排名口径与本企业岗位排名列表完全一致:avgRating DESC, jobId DESC(无评价 null 排末尾)
List<Long> orderedJobIds = jdbcTemplate.query(
"SELECT j.id FROM jobs j LEFT JOIN job_reviews jr ON jr.job_id=j.id AND " + REVIEW_FILTER + " " +
"WHERE j.company_id=? GROUP BY j.id, j.title, j.city, j.employment_type " +
"ORDER BY AVG(jr.rating) DESC, j.id DESC",
(rs, n) -> rs.getLong(1), company.getId());
int rank = orderedJobIds.indexOf(jobId) + 1;
return JobRankingItem.builder()
.jobId((Long) row[0]).title((String) row[1]).city((String) row[2]).employmentType((String) row[3])
.avgRating((BigDecimal) row[4]).reviewCount(((Number) row[5]).intValue())
.rank(rank).cohortSize(cohort.intValue())
.dimensionAverages(loadDimensionAverages(List.of(jobId)).getOrDefault(jobId, Map.of()))
.build();
}
// ── 候选人评分排名 ──────────────────────────────────────────
@Transactional(readOnly = true)
public CandidateRankingListResponse getCandidateRankings(Long companyUserId, int page, int size) {
Company company = requireCompany(companyUserId);
validatePage(page, size);
List<CandidateWithScore> candidates = loadCandidateScores(company.getId(), asOfPeriod());
computePercentiles(candidates);
candidates.sort(Comparator
.comparing((CandidateWithScore c) -> c.overallScore, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(Comparator.comparing((CandidateWithScore c) -> c.repApplicationId).reversed()));
return sliceCandidates(candidates, page, size);
}
@Transactional(readOnly = true)
public CandidateRankingDetailResponse getCandidateRanking(Long companyUserId, Long applicationId) {
Company company = requireCompany(companyUserId);
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId());
if (!company.getId().equals(job.getCompanyId())) {
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
}
if (EXCLUDED_APP_STATUS.contains(app.getStatus())) {
throw new BusinessException(ErrorCode.APPLICATION_NOT_FOUND);
}
String asOf = asOfPeriod();
List<CandidateWithScore> pool = loadCandidateScores(company.getId(), asOf);
computePercentiles(pool);
CandidateWithScore me = pool.stream()
.filter(c -> c.candidateUserId.equals(app.getStudentUserId()))
.findFirst().orElseGet(() -> {
CandidateWithScore c = new CandidateWithScore();
c.candidateUserId = app.getStudentUserId();
c.repApplicationId = applicationId;
c.repJobId = app.getJobId();
StudentProfile sp = studentProfileRepository.findByUserId(app.getStudentUserId()).orElse(null);
c.name = sp != null ? sp.getRealName() : null;
c.school = sp != null ? sp.getSchool() : null;
c.overallScore = null;
c.dimensionScores = List.of();
return c;
});
return CandidateRankingDetailResponse.builder()
.applicationId(applicationId).jobId(app.getJobId())
.candidateName(me.name).candidateSchool(me.school)
.overallScore(me.overallScore).percentile(me.percentile)
.cohortSize(pool.size()).dimensionScores(me.dimensionScores).build();
}
// ── 趋势 ────────────────────────────────────────────────────
@Transactional(readOnly = true)
public ScoreTrendResponse getTrend(Long companyUserId, int months) {
Company company = requireCompany(companyUserId);
if (months < 1 || months > 24) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "months must be between 1 and 24");
}
Long cid = company.getId();
LocalDate startDate = LocalDate.now().withDayOfMonth(1).minusMonths(months - 1);
String startPeriod = yearMonth(startDate);
String asOf = asOfPeriod();
// 岗位评价趋势:按 created_at 年月(Java 分组,兼容 MySQL/H2)
Map<String, List<BigDecimal>> jobByMonth = new TreeMap<>();
jdbcTemplate.query(
"SELECT jr.created_at, jr.rating FROM job_reviews jr JOIN jobs j ON jr.job_id=j.id " +
"WHERE j.company_id=? AND " + REVIEW_FILTER + " AND jr.created_at >= ?",
(rs, n) -> new Object[]{rs.getTimestamp(1).toLocalDateTime(), rs.getBigDecimal(2)},
cid, startDate.atStartOfDay())
.forEach(r -> jobByMonth.computeIfAbsent(yearMonth(((LocalDateTime) r[0]).toLocalDate()),
k -> new ArrayList<>()).add((BigDecimal) r[1]));
// 候选人综合分趋势:读取范围内各期快照,Java 计算每期 overallScore
List<Long> candidateIds = loadCandidateIds(cid);
Map<String, List<BigDecimal>> candByMonth = new TreeMap<>();
if (!candidateIds.isEmpty()) {
String in = candidateIds.stream().map(x -> "?").collect(Collectors.joining(","));
List<Object> args = new ArrayList<>(candidateIds);
args.add(startPeriod); args.add(asOf);
Map<Long, Map<String, List<CandidateDimensionScore>>> byCandPeriod = new HashMap<>();
jdbcTemplate.query(
"SELECT student_user_id, period, dimension, score, status FROM candidate_dimension_scores " +
"WHERE student_user_id IN (" + in + ") AND period >= ? AND period <= ?",
(rs, n) -> new Object[]{rs.getLong(1), rs.getString(2), rs.getString(3),
rs.getBigDecimal(4), rs.getString(5)},
args.toArray())
.forEach(r -> byCandPeriod
.computeIfAbsent((Long) r[0], k -> new HashMap<>())
.computeIfAbsent((String) r[1], k -> new ArrayList<>())
.add(CandidateDimensionScore.builder().studentUserId((Long) r[0])
.dimension((String) r[2]).score((BigDecimal) r[3])
.status((String) r[4]).period((String) r[1]).build()));
for (Map<String, List<CandidateDimensionScore>> periods : byCandPeriod.values()) {
for (Map.Entry<String, List<CandidateDimensionScore>> e : periods.entrySet()) {
BigDecimal overall = calculator.overallScoreFromSnapshots(e.getValue());
if (overall != null) {
candByMonth.computeIfAbsent(e.getKey(), k -> new ArrayList<>()).add(overall);
}
}
}
}
List<ScoreTrendResponse.TrendItem> items = new ArrayList<>();
TreeSet<String> periods = new TreeSet<>();
periods.addAll(jobByMonth.keySet());
periods.addAll(candByMonth.keySet());
for (String p : periods) {
items.add(ScoreTrendResponse.TrendItem.builder()
.period(p)
.avgJobScore(mean(jobByMonth.getOrDefault(p, List.of())))
.avgCandidateScore(mean(candByMonth.getOrDefault(p, List.of())))
.candidateCount(candByMonth.getOrDefault(p, List.of()).size()).build());
}
return ScoreTrendResponse.builder().companyId(cid).months(months).items(items).build();
}
// ── 全局概览(ADMIN) ───────────────────────────────────────
@Transactional(readOnly = true)
public AdminDashboardOverviewResponse getAdminOverview(Long adminUserId) {
assertAdminRole(adminUserId);
Integer totalCompanies = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM companies", Integer.class);
Integer totalJobs = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM jobs", Integer.class);
Integer totalCandidates = jdbcTemplate.queryForObject(
"SELECT COUNT(DISTINCT a.student_user_id) FROM applications a JOIN jobs j ON a.job_id=j.id " +
"WHERE a.status NOT IN ('WITHDRAWN','REJECTED')", Integer.class);
String asOf = asOfPeriod();
// 全部候选人最新期快照(<= asOf)→ 综合分 → 均值(小库规模可接受)
Map<Long, List<CandidateDimensionScore>> snapByCandidate = new HashMap<>();
jdbcTemplate.query(
"SELECT c.student_user_id, c.dimension, c.score, c.status FROM candidate_dimension_scores c " +
"JOIN (SELECT student_user_id, MAX(period) mp FROM candidate_dimension_scores " +
" WHERE period <= ? GROUP BY student_user_id) l " +
" ON c.student_user_id=l.student_user_id AND c.period=l.mp",
(rs, n) -> new Object[]{rs.getLong(1), rs.getString(2), rs.getBigDecimal(3), rs.getString(4)},
asOf)
.forEach(r -> snapByCandidate
.computeIfAbsent((Long) r[0], k -> new ArrayList<>())
.add(CandidateDimensionScore.builder().studentUserId((Long) r[0]).dimension((String) r[1])
.score((BigDecimal) r[2]).status((String) r[3]).build()));
List<BigDecimal> allScores = snapByCandidate.values().stream()
.map(calculator::overallScoreFromSnapshots)
.filter(Objects::nonNull)
.collect(Collectors.toList());
BigDecimal avgCandidateScore = mean(allScores);
// 企业维度:管线内去重候选人(排除 WITHDRAWN/REJECTED)→ 综合分 → 企业均值
// 综合分一律在 Java 复用 CandidateScoringCalculator,不在 SQL 重复实现公式
Map<Long, List<BigDecimal>> byCompany = new HashMap<>();
jdbcTemplate.query(
"SELECT DISTINCT a.student_user_id, j.company_id FROM applications a JOIN jobs j ON a.job_id=j.id " +
"WHERE a.status NOT IN ('WITHDRAWN','REJECTED')",
(rs, n) -> new Object[]{rs.getLong(1), rs.getLong(2)})
.forEach(r -> {
BigDecimal overall = calculator.overallScoreFromSnapshots(
snapByCandidate.getOrDefault((Long) r[0], List.of()));
if (overall != null) {
byCompany.computeIfAbsent((Long) r[1], k -> new ArrayList<>()).add(overall);
}
});
Map<Long, String> companyNames = new HashMap<>();
jdbcTemplate.query("SELECT id, name FROM companies",
(rs, n) -> new Object[]{rs.getLong(1), rs.getString(2)})
.forEach(r -> companyNames.put((Long) r[0], (String) r[1]));
Map<Long, Integer> jobCounts = new HashMap<>();
jdbcTemplate.query("SELECT company_id, COUNT(*) FROM jobs GROUP BY company_id",
(rs, n) -> new Object[]{rs.getLong(1), rs.getLong(2)})
.forEach(r -> jobCounts.put((Long) r[0], ((Number) r[1]).intValue()));
List<AdminDashboardOverviewResponse.CompanyScoreItem> top = byCompany.entrySet().stream()
.map(e -> AdminDashboardOverviewResponse.CompanyScoreItem.builder()
.companyId(e.getKey())
.companyName(companyNames.get(e.getKey()))
.avgCandidateScore(mean(e.getValue()))
.jobCount(jobCounts.getOrDefault(e.getKey(), 0))
.build())
.sorted(Comparator.comparing(AdminDashboardOverviewResponse.CompanyScoreItem::getAvgCandidateScore,
Comparator.nullsLast(Comparator.reverseOrder())))
.limit(10)
.collect(Collectors.toList());
return AdminDashboardOverviewResponse.builder()
.totalCompanies(totalCompanies).totalJobs(totalJobs)
.totalCandidates(totalCandidates).avgCandidateScore(avgCandidateScore)
.topCompanies(top).build();
}
// ── 私有:候选人池 / 综合分 / 百分位 ─────────────────────────
private List<CandidateWithScore> loadCandidateScores(Long companyId, String asOfPeriod) {
List<Object[]> pool = jdbcTemplate.query(
"SELECT a.student_user_id AS candidate, MAX(a.id) AS rep_app " +
"FROM applications a JOIN jobs j ON a.job_id=j.id " +
"WHERE j.company_id=? AND a.status NOT IN ('WITHDRAWN','REJECTED') " +
"GROUP BY a.student_user_id",
(rs, n) -> new Object[]{rs.getLong(1), rs.getLong(2)}, companyId);
if (pool.isEmpty()) return List.of();
List<Long> candidates = pool.stream().map(r -> (Long) r[0]).collect(Collectors.toList());
List<Long> repApps = pool.stream().map(r -> (Long) r[1]).collect(Collectors.toList());
// 代表投递的 jobId(批量)
Map<Long, Long> repJobByApp = new HashMap<>();
String appIn = repApps.stream().map(x -> "?").collect(Collectors.joining(","));
jdbcTemplate.query(
"SELECT id, job_id FROM applications WHERE id IN (" + appIn + ")",
(rs, n) -> new Object[]{rs.getLong(1), rs.getLong(2)}, repApps.toArray())
.forEach(r -> repJobByApp.put((Long) r[0], (Long) r[1]));
// 最新期快照(<= asOf,批量)
Map<Long, List<CandidateDimensionScore>> snapByCandidate = new HashMap<>();
String in = candidates.stream().map(x -> "?").collect(Collectors.joining(","));
List<Object> args = new ArrayList<>(candidates);
args.add(asOfPeriod);
jdbcTemplate.query(
"SELECT c.student_user_id, c.dimension, c.score, c.status, c.period " +
"FROM candidate_dimension_scores c " +
"JOIN (SELECT student_user_id, MAX(period) AS mp FROM candidate_dimension_scores " +
" WHERE student_user_id IN (" + in + ") AND period <= ? GROUP BY student_user_id) latest " +
" ON c.student_user_id=latest.student_user_id AND c.period=latest.mp",
(rs, n) -> new Object[]{rs.getLong(1), rs.getString(2), rs.getBigDecimal(3),
rs.getString(4), rs.getString(5)},
args.toArray())
.forEach(r -> snapByCandidate
.computeIfAbsent((Long) r[0], k -> new ArrayList<>())
.add(CandidateDimensionScore.builder().studentUserId((Long) r[0])
.dimension((String) r[1]).score((BigDecimal) r[2])
.status((String) r[3]).period((String) r[4]).build()));
// 学生档案(姓名/学校,批量)
Map<Long, StudentProfile> profiles = new HashMap<>();
studentProfileRepository.findAllById(candidates)
.forEach(sp -> profiles.put(sp.getUserId(), sp));
List<CandidateWithScore> out = new ArrayList<>();
for (Object[] row : pool) {
Long candidate = (Long) row[0];
Long repApp = (Long) row[1];
List<CandidateDimensionScore> snaps = snapByCandidate.getOrDefault(candidate, List.of());
StudentProfile sp = profiles.get(candidate);
CandidateWithScore c = new CandidateWithScore();
c.candidateUserId = candidate;
c.repApplicationId = repApp;
c.repJobId = repJobByApp.get(repApp);
c.name = sp != null ? sp.getRealName() : null;
c.school = sp != null ? sp.getSchool() : null;
c.overallScore = calculator.overallScoreFromSnapshots(snaps);
c.dimensionScores = snaps.stream()
.sorted(Comparator.comparing(CandidateDimensionScore::getDimension))
.map(s -> CandidateRankingDetailResponse.DimensionScoreItem.builder()
.dimension(s.getDimension()).score(s.getScore()).status(s.getStatus()).build())
.collect(Collectors.toList());
out.add(c);
}
return out;
}
private List<Long> loadCandidateIds(Long companyId) {
return jdbcTemplate.query(
"SELECT DISTINCT a.student_user_id FROM applications a JOIN jobs j ON a.job_id=j.id " +
"WHERE j.company_id=? AND a.status NOT IN ('WITHDRAWN','REJECTED')",
(rs, n) -> rs.getLong(1), companyId);
}
private void computePercentiles(List<CandidateWithScore> candidates) {
List<BigDecimal> scored = candidates.stream()
.map(c -> c.overallScore).filter(Objects::nonNull).collect(Collectors.toList());
if (scored.size() < 2) {
candidates.forEach(c -> c.percentile = null);
return;
}
for (CandidateWithScore c : candidates) {
if (c.overallScore == null) { c.percentile = null; continue; }
long lower = scored.stream().filter(s -> s.compareTo(c.overallScore) < 0).count();
c.percentile = BigDecimal.valueOf(lower)
.divide(BigDecimal.valueOf(scored.size() - 1), 10, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.setScale(2, RoundingMode.HALF_UP);
}
}
private Map<Long, Map<String, BigDecimal>> loadDimensionAverages(List<Long> jobIds) {
Map<Long, Map<String, BigDecimal>> result = new HashMap<>();
if (jobIds.isEmpty()) return result;
String in = jobIds.stream().map(x -> "?").collect(Collectors.joining(","));
jdbcTemplate.query(
"SELECT jr.job_id, jrds.dimension_code, AVG(jrds.score) " +
"FROM job_review_dimension_scores jrds JOIN job_reviews jr ON jrds.review_id=jr.id " +
"WHERE jr.job_id IN (" + in + ") AND " + REVIEW_FILTER + " " +
"GROUP BY jr.job_id, jrds.dimension_code",
(rs, n) -> new Object[]{rs.getLong(1), rs.getString(2), rs.getBigDecimal(3)},
jobIds.toArray())
.forEach(r -> result.computeIfAbsent((Long) r[0], k -> new HashMap<>())
.put((String) r[1], ((BigDecimal) r[2]).setScale(2, RoundingMode.HALF_UP)));
return result;
}
// ── 辅助 ────────────────────────────────────────────────────
private Company requireCompany(Long userId) {
return companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN));
}
private void assertAdminRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getRoles().stream().noneMatch(r -> r == User.Role.ADMIN)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private void validatePage(int page, int size) {
if (page < 0 || size < 1 || size > 100) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
}
private BigDecimal mean(List<BigDecimal> values) {
if (values.isEmpty()) return null;
BigDecimal sum = values.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
return sum.divide(BigDecimal.valueOf(values.size()), 2, RoundingMode.HALF_UP);
}
private BigDecimal norm(BigDecimal v) {
return v == null ? null : v.setScale(2, RoundingMode.HALF_UP);
}
private String yearMonth(LocalDate d) {
return String.format("%04d-%02d", d.getYear(), d.getMonthValue());
}
private String asOfPeriod() {
return yearMonth(LocalDate.now());
}
private JobRankingListResponse slice(List<JobRankingItem> all, int page, int size) {
int from = page * size;
int to = Math.min(from + size, all.size());
List<JobRankingItem> items = from >= all.size() ? List.of() : all.subList(from, to);
return JobRankingListResponse.builder().items(items).total(all.size())
.page(page).size(size).totalPages((int) Math.ceil((double) all.size() / size)).build();
}
private CandidateRankingListResponse sliceCandidates(List<CandidateWithScore> all, int page, int size) {
int from = page * size;
int to = Math.min(from + size, all.size());
List<CandidateRankingItem> items = from >= all.size() ? List.of() : all.subList(from, to).stream()
.map(c -> CandidateRankingItem.builder()
.applicationId(c.repApplicationId).jobId(c.repJobId)
.candidateName(c.name).candidateSchool(c.school)
.overallScore(c.overallScore).percentile(c.percentile)
.sampleCount((int) all.stream().map(x -> x.overallScore)
.filter(Objects::nonNull).count()).build())
.collect(Collectors.toList());
return CandidateRankingListResponse.builder().items(items).total(all.size())
.page(page).size(size).totalPages((int) Math.ceil((double) all.size() / size)).build();
}
static class CandidateWithScore {
Long candidateUserId;
Long repApplicationId;
Long repJobId;
String name;
String school;
BigDecimal overallScore;
BigDecimal percentile;
List<CandidateRankingDetailResponse.DimensionScoreItem> dimensionScores = List.of();
}
}

33
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/AdminDashboardOverviewResponse.java

@ -0,0 +1,33 @@
package com.mydreamjob.candidatescore.dashboard.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
* 全局运营概览(ADMIN)
*/
@Data
@Builder
public class AdminDashboardOverviewResponse {
private int totalCompanies;
private int totalJobs;
private int totalCandidates;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal avgCandidateScore;
private List<CompanyScoreItem> topCompanies;
@Data
@Builder
public static class CompanyScoreItem {
private Long companyId;
private String companyName;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal avgCandidateScore;
private int jobCount;
}
}

36
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/CandidateRankingDetailResponse.java

@ -0,0 +1,36 @@
package com.mydreamjob.candidatescore.dashboard.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
* 单候选人评分排名明细
*/
@Data
@Builder
public class CandidateRankingDetailResponse {
private Long applicationId;
private Long jobId;
private String candidateName;
private String candidateSchool;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal overallScore;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal percentile;
private int cohortSize;
private List<DimensionScoreItem> dimensionScores;
@Data
@Builder
public static class DimensionScoreItem {
private String dimension;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal score;
private String status;
}
}

25
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/CandidateRankingItem.java

@ -0,0 +1,25 @@
package com.mydreamjob.candidatescore.dashboard.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
/**
* 候选人评分排名项(按学生去重)
*/
@Data
@Builder
public class CandidateRankingItem {
private Long applicationId; // 代表投递
private Long jobId;
private String candidateName;
private String candidateSchool;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal overallScore; // 复用引擎公式,无有效维度 → null
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal percentile; // 有分候选人中计算,无分 → null
private int sampleCount; // 有分候选人数
}

20
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/CandidateRankingListResponse.java

@ -0,0 +1,20 @@
package com.mydreamjob.candidatescore.dashboard.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
/**
* 候选人评分排名分页响应
*/
@Data
@Builder
public class CandidateRankingListResponse {
private List<CandidateRankingItem> items;
private long total;
private int page;
private int size;
private int totalPages;
}

25
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/DashboardOverviewResponse.java

@ -0,0 +1,25 @@
package com.mydreamjob.candidatescore.dashboard.dto;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 企业评分运营概览
*/
@Data
@Builder
public class DashboardOverviewResponse {
private Long companyId;
private int totalJobs;
private int publishedJobs;
private BigDecimal avgJobScore; // 审核通过评价均值,无 → null
private int totalCandidates; // 管线内去重候选人(含无分)
private BigDecimal avgCandidateScore; // 有分候选人综合分均值,无 → null
private Map<Integer, Integer> scoreDistribution; // 1..5 全键
private LocalDateTime updatedAt;
}

27
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/JobRankingItem.java

@ -0,0 +1,27 @@
package com.mydreamjob.candidatescore.dashboard.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Map;
/**
* 岗位评分排名项
*/
@Data
@Builder
public class JobRankingItem {
private Long jobId;
private String title;
private String city;
private String employmentType;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal avgRating; // 审核通过评价均值,无 → null
private int reviewCount;
private int rank;
private int cohortSize;
private Map<String, BigDecimal> dimensionAverages; // dimensionCode -> 均值
}

20
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/JobRankingListResponse.java

@ -0,0 +1,20 @@
package com.mydreamjob.candidatescore.dashboard.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
/**
* 岗位评分排名分页响应
*/
@Data
@Builder
public class JobRankingListResponse {
private List<JobRankingItem> items;
private long total;
private int page;
private int size;
private int totalPages;
}

31
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/ScoreTrendResponse.java

@ -0,0 +1,31 @@
package com.mydreamjob.candidatescore.dashboard.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
* 企业评分趋势(按月)
*/
@Data
@Builder
public class ScoreTrendResponse {
private Long companyId;
private int months;
private List<TrendItem> items;
@Data
@Builder
public static class TrendItem {
private String period;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal avgJobScore;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal avgCandidateScore;
private int candidateCount;
}
}

38
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoringOverviewResponse.java

@ -0,0 +1,38 @@
package com.mydreamjob.candidatescore.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* 求职者评分体系 - 综合评分卡(仅 STUDENT 本人)
*/
@Data
@Builder
public class CandidateScoringOverviewResponse {
private Long candidateId;
private String status; // OK / PARTIAL / INSUFFICIENT_DATA / PENDING
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal overallScore;
private Map<String, BigDecimal> weights;
private List<DimensionItem> dimensions;
private String rulesVersion;
private Instant generatedAt;
@Data
@Builder
public static class DimensionItem {
private String dimension;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal score;
private String status; // OK / ESTIMATED / INSUFFICIENT_DATA / PENDING
private BigDecimal weight;
private String summary;
}
}

31
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoringTrendResponse.java

@ -0,0 +1,31 @@
package com.mydreamjob.candidatescore.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/**
* 求职者评分体系 - 趋势(基于快照,按 period 独立归一化,仅 STUDENT 本人)
*/
@Data
@Builder
public class CandidateScoringTrendResponse {
private Long candidateId;
private int months;
private List<TrendItem> items;
@Data
@Builder
public static class TrendItem {
private String period;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal overallScore;
private String rulesVersion;
private Map<String, BigDecimal> dimensions; // 仅含 OK/ESTIMATED 维度
}
}

36
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CompanyCandidateScoringResponse.java

@ -0,0 +1,36 @@
package com.mydreamjob.candidatescore.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
/**
* 求职者评分体系 - 企业只读评分卡
* <p>仅允许返回 applicationId/overallScore/status/dimensions/generatedAt/rulesVersion;
* 禁止返回 studentUserId/candidateId/phone/email/resumeUrl/sub_scores 细节。</p>
*/
@Data
@Builder
public class CompanyCandidateScoringResponse {
private Long applicationId;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal overallScore;
private String status;
private List<CompanyDimensionItem> dimensions;
private Instant generatedAt;
private String rulesVersion;
@Data
@Builder
public static class CompanyDimensionItem {
private String dimension;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal score;
private String status;
}
}

44
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/DimensionScoreDetailResponse.java

@ -0,0 +1,44 @@
package com.mydreamjob.candidatescore.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 求职者评分体系 - 单维度明细(仅 STUDENT 本人)
*/
@Data
@Builder
public class DimensionScoreDetailResponse {
private String dimension;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal score;
private String status;
private List<SubScore> subScores;
private List<String> reasons;
private List<DataSourceItem> dataSources;
private LocalDateTime updatedAt;
private String rulesVersion;
@Data
@Builder
public static class SubScore {
private String name;
private BigDecimal score;
private BigDecimal weight;
private String description;
}
@Data
@Builder
public static class DataSourceItem {
private String source;
private LocalDateTime time;
private String note;
}
}

101
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppeal.java

@ -0,0 +1,101 @@
package com.mydreamjob.candidatescore.governance;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 候选人评分申诉(评分治理)
* <p>active_key 唯一保护同一学生、维度、周期的 PENDING/APPROVED 申诉;终态清空 active_key 允许重新提交。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "candidate_scoring_appeals",
uniqueConstraints = {
@UniqueConstraint(name = "uk_csa_idem", columnNames = "idempotency_key"),
@UniqueConstraint(name = "uk_csa_active", columnNames = "active_key")
})
@EntityListeners(AuditingEntityListener.class)
public class CandidateScoringAppeal {
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_APPROVED = "APPROVED";
public static final String STATUS_REJECTED = "REJECTED";
public static final String STATUS_CANCELLED = "CANCELLED";
public static final String STATUS_ROLLED_BACK = "ROLLED_BACK";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "candidate_user_id", nullable = false)
private Long candidateUserId;
@Column(name = "dimension", nullable = false, length = 20)
private String dimension;
/** yyyy-MM */
@Column(name = "period", nullable = false, length = 7)
private String period;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "reason", nullable = false, length = 500)
private String reason;
@Column(name = "decision", length = 20)
private String decision;
@Column(name = "decision_reason", length = 500)
private String decisionReason;
@Column(name = "reviewer_id")
private Long reviewerId;
@Column(name = "reviewed_at")
private LocalDateTime reviewedAt;
@Column(name = "rollback_at")
private LocalDateTime rollbackAt;
@Column(name = "score_at_appeal", precision = 5, scale = 2)
private BigDecimal scoreAtAppeal;
@Column(name = "snapshot_version_at_appeal")
private Long snapshotVersionAtAppeal;
@Column(name = "rules_version", nullable = false, length = 40)
private String rulesVersion;
@Column(name = "idempotency_key", length = 64)
private String idempotencyKey;
/** 仅 PENDING/APPROVED 非空;终态置 NULL */
@Column(name = "active_key", length = 64)
private String activeKey;
@Version
@Column(name = "version", nullable = false)
@Builder.Default
private Long version = 0L;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

51
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistory.java

@ -0,0 +1,51 @@
package com.mydreamjob.candidatescore.governance;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 申诉状态流转历史
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "candidate_scoring_appeal_history")
@EntityListeners(AuditingEntityListener.class)
public class CandidateScoringAppealHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "appeal_id", nullable = false)
private Long appealId;
@Column(name = "from_status", length = 20)
private String fromStatus;
@Column(name = "to_status", nullable = false, length = 20)
private String toStatus;
@Column(name = "actor_user_id", nullable = false)
private Long actorUserId;
/** STUDENT / ADMIN */
@Column(name = "actor_role", nullable = false, length = 20)
private String actorRole;
@Column(name = "reason", length = 500)
private String reason;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

12
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistoryRepository.java

@ -0,0 +1,12 @@
package com.mydreamjob.candidatescore.governance;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface CandidateScoringAppealHistoryRepository extends JpaRepository<CandidateScoringAppealHistory, Long> {
List<CandidateScoringAppealHistory> findByAppealIdOrderByCreatedAtAsc(Long appealId);
}

32
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealRepository.java

@ -0,0 +1,32 @@
package com.mydreamjob.candidatescore.governance;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
@Repository
public interface CandidateScoringAppealRepository extends JpaRepository<CandidateScoringAppeal, Long> {
Page<CandidateScoringAppeal> findByCandidateUserIdOrderByCreatedAtDesc(Long candidateUserId, Pageable pageable);
Page<CandidateScoringAppeal> findByCandidateUserIdAndStatusOrderByCreatedAtDesc(
Long candidateUserId, String status, Pageable pageable);
Page<CandidateScoringAppeal> findAllByOrderByCreatedAtDesc(Pageable pageable);
Page<CandidateScoringAppeal> findByStatusOrderByCreatedAtDesc(String status, Pageable pageable);
Optional<CandidateScoringAppeal> findByCandidateUserIdAndId(Long candidateUserId, Long id);
Optional<CandidateScoringAppeal> findByActiveKey(String activeKey);
Optional<CandidateScoringAppeal> findByCandidateUserIdAndIdempotencyKey(Long candidateUserId, String idempotencyKey);
List<CandidateScoringAppeal> findByCandidateUserIdAndDimensionAndPeriodAndStatusIn(
Long candidateUserId, String dimension, String period, Collection<String> statuses);
}

152
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceController.java

@ -0,0 +1,152 @@
package com.mydreamjob.candidatescore.governance;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.candidatescore.governance.dto.AppealDecisionRequest;
import com.mydreamjob.candidatescore.governance.dto.AppealHistoryItem;
import com.mydreamjob.candidatescore.governance.dto.AppealResponse;
import com.mydreamjob.candidatescore.governance.dto.CreateAppealRequest;
import com.mydreamjob.candidatescore.governance.dto.SnapshotHistoryItem;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
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.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 评分治理基础模块:申诉、审核、回滚、审计
*/
@Tag(name = "评分治理", description = "求职者评分申诉、审核、回滚与审计")
@RestController
@RequiredArgsConstructor
public class CandidateScoringGovernanceController {
private final CandidateScoringGovernanceService governanceService;
@Operation(summary = "提交评分申诉", description = "仅 STUDENT 本人;同一学生+维度+周期 PENDING/APPROVED 唯一")
@PostMapping("/profile/me/candidate-scoring/appeals")
public ResponseEntity<ApiResponse<AppealResponse>> createAppeal(
@Valid @RequestBody CreateAppealRequest req, HttpServletRequest request) {
Long userId = resolveUserId();
AppealResponse data = governanceService.createAppeal(userId, req, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "我的申诉列表", description = "仅 STUDENT 本人")
@GetMapping("/profile/me/candidate-scoring/appeals")
public ResponseEntity<ApiResponse<Map<String, Object>>> listMyAppeals(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId();
Page<AppealResponse> result = governanceService.listMyAppeals(userId, status, page, size);
return ResponseEntity.ok(ApiResponse.ok(pageView(result), getRequestId(request)));
}
@Operation(summary = "申诉详情", description = "本人或 ADMIN")
@GetMapping("/profile/me/candidate-scoring/appeals/{appealId}")
public ResponseEntity<ApiResponse<AppealResponse>> getAppeal(
@PathVariable Long appealId, HttpServletRequest request) {
Long userId = resolveUserId();
AppealResponse data = governanceService.getAppeal(userId, appealId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "撤销申诉", description = "仅本人;仅 PENDING 可撤销")
@PostMapping("/profile/me/candidate-scoring/appeals/{appealId}/cancel")
public ResponseEntity<ApiResponse<AppealResponse>> cancelAppeal(
@PathVariable Long appealId, HttpServletRequest request) {
Long userId = resolveUserId();
AppealResponse data = governanceService.cancelAppeal(userId, appealId, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "全部申诉列表", description = "仅 ADMIN")
@GetMapping("/admin/candidate-scoring/appeals")
public ResponseEntity<ApiResponse<Map<String, Object>>> listAllAppeals(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId();
Page<AppealResponse> result = governanceService.listAllAppeals(userId, status, page, size);
return ResponseEntity.ok(ApiResponse.ok(pageView(result), getRequestId(request)));
}
@Operation(summary = "审核决定", description = "仅 ADMIN;APPROVE/REJECT,REJECT 必须填 reason")
@PostMapping("/admin/candidate-scoring/appeals/{appealId}/decision")
public ResponseEntity<ApiResponse<AppealResponse>> decideAppeal(
@PathVariable Long appealId,
@Valid @RequestBody AppealDecisionRequest req, HttpServletRequest request) {
Long userId = resolveUserId();
AppealResponse data = governanceService.decideAppeal(userId, appealId, req, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "执行回滚", description = "仅 ADMIN;记录前态+确定性重算+新版本;年 3 次上限")
@PostMapping("/admin/candidate-scoring/appeals/{appealId}/rollback")
public ResponseEntity<ApiResponse<AppealResponse>> rollbackAppeal(
@PathVariable Long appealId, HttpServletRequest request) {
Long userId = resolveUserId();
AppealResponse data = governanceService.rollbackAppeal(userId, appealId, AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "申诉历史", description = "本人(脱敏,无 actorUserId)或 ADMIN(全量);此路径的本人放行先于 ADMIN 通用规则")
@GetMapping("/admin/candidate-scoring/appeals/{appealId}/history")
public ResponseEntity<ApiResponse<List<AppealHistoryItem>>> getAppealHistory(
@PathVariable Long appealId, HttpServletRequest request) {
Long userId = resolveUserId();
List<AppealHistoryItem> data = governanceService.getAppealHistory(userId, appealId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "快照变更审计", description = "仅 ADMIN;按学生+维度+周期查看回滚前后完整变化")
@GetMapping("/admin/candidate-scoring/snapshots/{candidateUserId}/{dimension}/{period}/history")
public ResponseEntity<ApiResponse<List<SnapshotHistoryItem>>> getSnapshotHistory(
@PathVariable Long candidateUserId,
@PathVariable String dimension,
@PathVariable String period,
HttpServletRequest request) {
Long userId = resolveUserId();
List<SnapshotHistoryItem> data = governanceService.getSnapshotHistory(
userId, candidateUserId, dimension, period);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
// ── 辅助 ────────────────────────────────────────────────────
private Map<String, Object> pageView(Page<?> page) {
Map<String, Object> data = new HashMap<>();
data.put("items", page.getContent());
data.put("total", page.getTotalElements());
data.put("page", page.getNumber());
data.put("size", page.getSize());
data.put("totalPages", page.getTotalPages());
return data;
}
private Long resolveUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
return Long.valueOf(auth.getName());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

387
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java

@ -0,0 +1,387 @@
package com.mydreamjob.candidatescore.governance;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScore;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import com.mydreamjob.candidatescore.governance.dto.*;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.Year;
import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* 评分治理基础模块:申诉、审核、回滚、审计。
* <p>回滚 = 记录前态 + 按当前数据确定性重算(复用 CandidateScoringCalculator)+ 写新快照版本 + 记录后态。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CandidateScoringGovernanceService {
public static final int MAX_ROLLBACK_PER_YEAR = 3;
private static final Pattern PERIOD_PATTERN = Pattern.compile("^\\d{4}-(0[1-9]|1[0-2])$");
private final CandidateScoringAppealRepository appealRepository;
private final CandidateScoringAppealHistoryRepository appealHistoryRepository;
private final CandidateScoringSnapshotHistoryRepository snapshotHistoryRepository;
private final CandidateScoringRollbackLimitRepository rollbackLimitRepository;
private final CandidateDimensionScoreRepository snapshotRepository;
private final CandidateScoringCalculator calculator;
private final UserRepository userRepository;
private final AuditService auditService;
@PersistenceContext
private EntityManager entityManager;
// ── 角色校验 ────────────────────────────────────────────────
private void assertStudentRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getRoles().stream().noneMatch(r -> r == User.Role.STUDENT)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private void assertAdminRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getRoles().stream().noneMatch(r -> r == User.Role.ADMIN)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private boolean isAdmin(Long userId) {
return userRepository.findById(userId)
.map(u -> u.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN))
.orElse(false);
}
// ── 提交申诉 ────────────────────────────────────────────────
@Transactional
public AppealResponse createAppeal(Long studentUserId, CreateAppealRequest req, AuditContext ctx) {
assertStudentRole(studentUserId);
if (!CandidateScoringCalculator.isValidDimension(req.getDimension())) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension: " + req.getDimension());
}
if (!PERIOD_PATTERN.matcher(req.getPeriod()).matches()) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid period: " + req.getPeriod());
}
if (req.getReason() == null || req.getReason().isBlank()) {
throw new BusinessException(ErrorCode.APPEAL_REASON_REQUIRED);
}
String activeKey = studentUserId + ":" + req.getDimension() + ":" + req.getPeriod();
// 幂等:同 idempotencyKey 同请求体 → 200;不同请求体 → 409/8607
if (req.getIdempotencyKey() != null && !req.getIdempotencyKey().isBlank()) {
Optional<CandidateScoringAppeal> byIdem =
appealRepository.findByCandidateUserIdAndIdempotencyKey(studentUserId, req.getIdempotencyKey());
if (byIdem.isPresent()) {
CandidateScoringAppeal ex = byIdem.get();
boolean sameBody = req.getDimension().equals(ex.getDimension())
&& req.getPeriod().equals(ex.getPeriod())
&& req.getReason().trim().equals(ex.getReason());
if (!sameBody) {
throw new BusinessException(ErrorCode.APPEAL_IDEMPOTENCY_CONFLICT);
}
return toView(ex, true);
}
}
// 同一学生+维度+周期已有 PENDING/APPROVED → 409/8602(active_key 唯一兜底)
if (appealRepository.findByActiveKey(activeKey).isPresent()) {
throw new BusinessException(ErrorCode.APPEAL_DUPLICATE);
}
CandidateDimensionScore snap = findSnapshot(studentUserId, req.getDimension(), req.getPeriod());
CandidateScoringAppeal appeal = CandidateScoringAppeal.builder()
.candidateUserId(studentUserId).dimension(req.getDimension()).period(req.getPeriod())
.status(CandidateScoringAppeal.STATUS_PENDING).reason(req.getReason().trim())
.rulesVersion(CandidateScoringCalculator.RULES_VERSION)
.idempotencyKey(req.getIdempotencyKey())
.activeKey(activeKey)
.scoreAtAppeal(snap != null ? snap.getScore() : null)
.snapshotVersionAtAppeal(snap != null ? snap.getVersion() : null)
.build();
try {
appeal = appealRepository.saveAndFlush(appeal);
} catch (DataIntegrityViolationException e) {
// active_key / idempotency_key 唯一冲突(并发创建)
Optional<CandidateScoringAppeal> byIdem =
appealRepository.findByCandidateUserIdAndIdempotencyKey(studentUserId, req.getIdempotencyKey());
if (byIdem.isPresent()) {
CandidateScoringAppeal ex = byIdem.get();
boolean sameBody = req.getDimension().equals(ex.getDimension())
&& req.getPeriod().equals(ex.getPeriod())
&& req.getReason().trim().equals(ex.getReason());
if (sameBody) return toView(ex, true);
throw new BusinessException(ErrorCode.APPEAL_IDEMPOTENCY_CONFLICT);
}
throw new BusinessException(ErrorCode.APPEAL_DUPLICATE);
}
recordHistory(appeal.getId(), null, CandidateScoringAppeal.STATUS_PENDING,
studentUserId, "STUDENT", "Student appealed");
auditService.log(studentUserId, "APPEAL_SUBMIT", "CANDIDATE_SCORING_APPEAL", appeal.getId(),
"appeal submitted: dim=" + req.getDimension() + ", period=" + req.getPeriod(), ctx);
return toView(appeal, true);
}
// ── 我的申诉列表 ────────────────────────────────────────────
public Page<AppealResponse> listMyAppeals(Long studentUserId, String status, int page, int size) {
assertStudentRole(studentUserId);
validatePage(page, size);
PageRequest pr = PageRequest.of(page, size);
Page<CandidateScoringAppeal> p = (status != null && !status.isBlank())
? appealRepository.findByCandidateUserIdAndStatusOrderByCreatedAtDesc(studentUserId, status, pr)
: appealRepository.findByCandidateUserIdOrderByCreatedAtDesc(studentUserId, pr);
return p.map(a -> toView(a, true));
}
// ── 申诉详情 ────────────────────────────────────────────────
public AppealResponse getAppeal(Long callerUserId, Long appealId) {
CandidateScoringAppeal appeal = requireAppeal(appealId);
boolean admin = isAdmin(callerUserId);
if (!admin && !appeal.getCandidateUserId().equals(callerUserId)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return toView(appeal, admin);
}
// ── 撤销申诉 ────────────────────────────────────────────────
@Transactional
public AppealResponse cancelAppeal(Long studentUserId, Long appealId, AuditContext ctx) {
assertStudentRole(studentUserId);
CandidateScoringAppeal appeal = requireAppeal(appealId);
if (!appeal.getCandidateUserId().equals(studentUserId)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
if (!CandidateScoringAppeal.STATUS_PENDING.equals(appeal.getStatus())) {
throw new BusinessException(ErrorCode.APPEAL_INVALID_TRANSITION);
}
String from = appeal.getStatus();
appeal.setStatus(CandidateScoringAppeal.STATUS_CANCELLED);
appeal.setActiveKey(null); // 终态清空 active_key
appealRepository.save(appeal);
recordHistory(appeal.getId(), from, CandidateScoringAppeal.STATUS_CANCELLED,
studentUserId, "STUDENT", "Student cancelled appeal");
auditService.log(studentUserId, "APPEAL_CANCEL", "CANDIDATE_SCORING_APPEAL", appeal.getId(),
"appeal cancelled", ctx);
return toView(appeal, true);
}
// ── 全部申诉列表(ADMIN) ───────────────────────────────────
public Page<AppealResponse> listAllAppeals(Long adminUserId, String status, int page, int size) {
assertAdminRole(adminUserId);
validatePage(page, size);
PageRequest pr = PageRequest.of(page, size);
Page<CandidateScoringAppeal> p = (status != null && !status.isBlank())
? appealRepository.findByStatusOrderByCreatedAtDesc(status, pr)
: appealRepository.findAllByOrderByCreatedAtDesc(pr);
return p.map(a -> toView(a, true));
}
// ── 审核决定 ────────────────────────────────────────────────
@Transactional
public AppealResponse decideAppeal(Long adminUserId, Long appealId, AppealDecisionRequest req, AuditContext ctx) {
assertAdminRole(adminUserId);
CandidateScoringAppeal appeal = requireAppeal(appealId);
String target = req.getDecision();
if (!"APPROVE".equals(target) && !"REJECT".equals(target)) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "decision must be APPROVE or REJECT");
}
// 重复同态终态 → 幂等 200(APPROVED+APPROVE / REJECTED+REJECT)
if (CandidateScoringAppeal.STATUS_REJECTED.equals(appeal.getStatus()) && "REJECT".equals(target)) {
return toView(appeal, true);
}
if (CandidateScoringAppeal.STATUS_APPROVED.equals(appeal.getStatus()) && "APPROVE".equals(target)) {
return toView(appeal, true);
}
if (!CandidateScoringAppeal.STATUS_PENDING.equals(appeal.getStatus())) {
throw new BusinessException(ErrorCode.APPEAL_ALREADY_DECIDED);
}
if ("REJECT".equals(target) && (req.getReason() == null || req.getReason().isBlank())) {
throw new BusinessException(ErrorCode.APPEAL_REASON_REQUIRED);
}
String from = appeal.getStatus();
try {
appeal.setStatus("APPROVE".equals(target)
? CandidateScoringAppeal.STATUS_APPROVED : CandidateScoringAppeal.STATUS_REJECTED);
appeal.setDecision(target);
appeal.setDecisionReason(req.getReason());
appeal.setReviewerId(adminUserId);
appeal.setReviewedAt(LocalDateTime.now());
if (CandidateScoringAppeal.STATUS_REJECTED.equals(appeal.getStatus())) {
appeal.setActiveKey(null);
}
appealRepository.saveAndFlush(appeal);
} catch (OptimisticLockingFailureException e) {
throw new BusinessException(ErrorCode.APPEAL_INVALID_TRANSITION, "appeal was modified concurrently");
}
recordHistory(appeal.getId(), from, appeal.getStatus(), adminUserId, "ADMIN", "Admin " + target);
auditService.log(adminUserId, "APPEAL_DECIDE", "CANDIDATE_SCORING_APPEAL", appeal.getId(),
"appeal decided: " + target, ctx);
return toView(appeal, true);
}
// ── 回滚(记录前态 + 确定性重算 + 新版本 + 记录后态) ─────────
@Transactional
public AppealResponse rollbackAppeal(Long adminUserId, Long appealId, AuditContext ctx) {
assertAdminRole(adminUserId);
CandidateScoringAppeal appeal = requireAppeal(appealId);
if (CandidateScoringAppeal.STATUS_ROLLED_BACK.equals(appeal.getStatus())) {
return toView(appeal, true); // 幂等,不重复写历史
}
if (!CandidateScoringAppeal.STATUS_APPROVED.equals(appeal.getStatus())) {
throw new BusinessException(ErrorCode.APPEAL_INVALID_TRANSITION);
}
Long candidateId = appeal.getCandidateUserId();
int year = Year.now().getValue();
// 并发安全:确保 rollback_limits 行存在(INSERT IGNORE 不抛唯一冲突),再悲观锁串行化
entityManager.createNativeQuery(
"INSERT IGNORE INTO candidate_scoring_rollback_limits (student_user_id, rollback_year, rollback_count) " +
"VALUES (?, ?, 0)")
.setParameter(1, candidateId)
.setParameter(2, year)
.executeUpdate();
CandidateScoringRollbackLimit limit = rollbackLimitRepository
.findByStudentUserIdAndRollbackYear(candidateId, year)
.orElseThrow(() -> new IllegalStateException("rollback limit row missing"));
if (limit.getRollbackCount() >= MAX_ROLLBACK_PER_YEAR) {
throw new BusinessException(ErrorCode.APPEAL_ROLLBACK_LIMIT_EXCEEDED);
}
// 回滚前快照
CandidateDimensionScore before = findSnapshot(candidateId, appeal.getDimension(), appeal.getPeriod());
// 按当前数据确定性重算并写新版本
CandidateScoringCalculator.DimensionResult after = calculator.recomputeAndSnapshot(
candidateId, appeal.getDimension(), appeal.getPeriod());
// 原生 upsert 固定 version=version+1:afterVersion 确定性计算(避免读取持久化上下文旧值)
Long afterVersion = before != null ? before.getVersion() + 1 : 0L;
snapshotHistoryRepository.save(CandidateScoringSnapshotHistory.builder()
.appealId(appeal.getId()).candidateUserId(candidateId)
.dimension(appeal.getDimension()).period(appeal.getPeriod()).action("ROLLBACK")
.beforeScore(before != null ? before.getScore() : null)
.afterScore(after.score)
.beforeVersion(before != null ? before.getVersion() : null)
.afterVersion(afterVersion)
.beforeRulesVersion(before != null ? before.getRulesVersion() : null)
.afterRulesVersion(CandidateScoringCalculator.RULES_VERSION)
.build());
String from = appeal.getStatus();
appeal.setStatus(CandidateScoringAppeal.STATUS_ROLLED_BACK);
appeal.setRollbackAt(LocalDateTime.now());
appeal.setActiveKey(null); // 终态清空 active_key
appealRepository.save(appeal);
recordHistory(appeal.getId(), from, CandidateScoringAppeal.STATUS_ROLLED_BACK,
adminUserId, "ADMIN", "Rollback executed (deterministic recompute)");
auditService.log(adminUserId, "APPEAL_ROLLBACK", "CANDIDATE_SCORING_APPEAL", appeal.getId(),
"rollback executed: dim=" + appeal.getDimension() + ", period=" + appeal.getPeriod(), ctx);
limit.setRollbackCount(limit.getRollbackCount() + 1);
rollbackLimitRepository.save(limit);
return toView(appeal, true);
}
// ── 申诉历史(学生脱敏 / ADMIN 全量) ───────────────────────
public List<AppealHistoryItem> getAppealHistory(Long callerUserId, Long appealId) {
CandidateScoringAppeal appeal = requireAppeal(appealId);
boolean admin = isAdmin(callerUserId);
if (!admin && !appeal.getCandidateUserId().equals(callerUserId)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return appealHistoryRepository.findByAppealIdOrderByCreatedAtAsc(appealId).stream()
.map(h -> AppealHistoryItem.builder()
.id(h.getId()).fromStatus(h.getFromStatus()).toStatus(h.getToStatus())
.actorUserId(admin ? h.getActorUserId() : null)
.actorRole(h.getActorRole())
.reason(h.getReason()).createdAt(h.getCreatedAt()).build())
.collect(Collectors.toList());
}
// ── 快照变更审计(ADMIN) ───────────────────────────────────
public List<SnapshotHistoryItem> getSnapshotHistory(Long adminUserId, Long candidateUserId,
String dimension, String period) {
assertAdminRole(adminUserId);
return snapshotHistoryRepository
.findByCandidateUserIdAndDimensionAndPeriodOrderByCreatedAtAsc(candidateUserId, dimension, period)
.stream().map(s -> SnapshotHistoryItem.builder()
.id(s.getId()).appealId(s.getAppealId()).action(s.getAction())
.dimension(s.getDimension()).period(s.getPeriod())
.beforeScore(s.getBeforeScore()).afterScore(s.getAfterScore())
.beforeVersion(s.getBeforeVersion()).afterVersion(s.getAfterVersion())
.beforeRulesVersion(s.getBeforeRulesVersion()).afterRulesVersion(s.getAfterRulesVersion())
.createdAt(s.getCreatedAt()).build())
.collect(Collectors.toList());
}
// ── 辅助 ────────────────────────────────────────────────────
private CandidateScoringAppeal requireAppeal(Long appealId) {
return appealRepository.findById(appealId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPEAL_NOT_FOUND));
}
private CandidateDimensionScore findSnapshot(Long studentUserId, String dimension, String period) {
return snapshotRepository.findByStudentUserIdAndPeriod(studentUserId, period).stream()
.filter(s -> s.getDimension().equals(dimension))
.findFirst().orElse(null);
}
private void validatePage(int page, int size) {
if (page < 0 || size < 1 || size > 100) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
}
private void recordHistory(Long appealId, String fromStatus, String toStatus,
Long actorUserId, String actorRole, String reason) {
appealHistoryRepository.save(CandidateScoringAppealHistory.builder()
.appealId(appealId).fromStatus(fromStatus).toStatus(toStatus)
.actorUserId(actorUserId).actorRole(actorRole).reason(reason).build());
}
private AppealResponse toView(CandidateScoringAppeal a, boolean admin) {
return AppealResponse.builder()
.appealId(a.getId()).candidateId(a.getCandidateUserId())
.dimension(a.getDimension()).period(a.getPeriod())
.status(a.getStatus()).reason(a.getReason())
.decision(a.getDecision()).decisionReason(a.getDecisionReason())
.reviewerId(admin ? a.getReviewerId() : null)
.reviewedAt(a.getReviewedAt()).rollbackAt(a.getRollbackAt())
.scoreAtAppeal(a.getScoreAtAppeal()).snapshotVersionAtAppeal(a.getSnapshotVersionAtAppeal())
.rulesVersion(a.getRulesVersion()).createdAt(a.getCreatedAt()).updatedAt(a.getUpdatedAt())
.build();
}
}

44
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringRollbackLimit.java

@ -0,0 +1,44 @@
package com.mydreamjob.candidatescore.governance;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 每学生每自然年回滚次数限制(并发安全:事务内 SELECT ... FOR UPDATE 锁定本行)
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "candidate_scoring_rollback_limits",
uniqueConstraints = @UniqueConstraint(name = "uk_csrl_student_year",
columnNames = {"student_user_id", "rollback_year"}))
@EntityListeners(AuditingEntityListener.class)
public class CandidateScoringRollbackLimit {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "student_user_id", nullable = false)
private Long studentUserId;
@Column(name = "rollback_year", nullable = false)
private Integer rollbackYear;
@Column(name = "rollback_count", nullable = false)
@Builder.Default
private Integer rollbackCount = 0;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

16
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringRollbackLimitRepository.java

@ -0,0 +1,16 @@
package com.mydreamjob.candidatescore.governance;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface CandidateScoringRollbackLimitRepository extends JpaRepository<CandidateScoringRollbackLimit, Long> {
/** 悲观锁:同一学生同一年份仅一行,用于串行化并发回滚并稳定统计第 4 次 */
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<CandidateScoringRollbackLimit> findByStudentUserIdAndRollbackYear(Long studentUserId, Integer rollbackYear);
}

66
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringSnapshotHistory.java

@ -0,0 +1,66 @@
package com.mydreamjob.candidatescore.governance;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 快照变更审计(回滚前后完整变化)
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "candidate_scoring_snapshot_history")
@EntityListeners(AuditingEntityListener.class)
public class CandidateScoringSnapshotHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "appeal_id", nullable = false)
private Long appealId;
@Column(name = "candidate_user_id", nullable = false)
private Long candidateUserId;
@Column(name = "dimension", nullable = false, length = 20)
private String dimension;
@Column(name = "period", nullable = false, length = 7)
private String period;
@Column(name = "action", nullable = false, length = 20)
private String action;
@Column(name = "before_score", precision = 5, scale = 2)
private BigDecimal beforeScore;
@Column(name = "after_score", precision = 5, scale = 2)
private BigDecimal afterScore;
@Column(name = "before_version")
private Long beforeVersion;
@Column(name = "after_version")
private Long afterVersion;
@Column(name = "before_rules_version", length = 40)
private String beforeRulesVersion;
@Column(name = "after_rules_version", length = 40)
private String afterRulesVersion;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

15
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringSnapshotHistoryRepository.java

@ -0,0 +1,15 @@
package com.mydreamjob.candidatescore.governance;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface CandidateScoringSnapshotHistoryRepository extends JpaRepository<CandidateScoringSnapshotHistory, Long> {
List<CandidateScoringSnapshotHistory> findByCandidateUserIdAndDimensionAndPeriodOrderByCreatedAtAsc(
Long candidateUserId, String dimension, String period);
List<CandidateScoringSnapshotHistory> findByAppealIdOrderByCreatedAtAsc(Long appealId);
}

17
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/AppealDecisionRequest.java

@ -0,0 +1,17 @@
package com.mydreamjob.candidatescore.governance.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 审核决定请求
*/
@Data
public class AppealDecisionRequest {
@NotBlank(message = "decision is required")
private String decision; // APPROVE / REJECT
/** REJECT 时必填(服务层校验,返回 8604) */
private String reason;
}

22
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/AppealHistoryItem.java

@ -0,0 +1,22 @@
package com.mydreamjob.candidatescore.governance.dto;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 申诉历史项(学生端不返回 actorUserId,置 null 由 NON_NULL 省略;ADMIN 端返回)
*/
@Data
@Builder
public class AppealHistoryItem {
private Long id;
private String fromStatus;
private String toStatus;
private Long actorUserId;
private String actorRole; // STUDENT / ADMIN
private String reason;
private LocalDateTime createdAt;
}

34
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/AppealResponse.java

@ -0,0 +1,34 @@
package com.mydreamjob.candidatescore.governance.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 申诉视图(学生端不返回 reviewerId,置 null 由全局 NON_NULL 序列化省略)
*/
@Data
@Builder
public class AppealResponse {
private Long appealId;
private Long candidateId;
private String dimension;
private String period;
private String status;
private String reason;
private String decision;
private String decisionReason;
private Long reviewerId; // 学生端不返回:null 时由全局 NON_NULL 序列化省略
private LocalDateTime reviewedAt;
private LocalDateTime rollbackAt;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal scoreAtAppeal;
private Long snapshotVersionAtAppeal;
private String rulesVersion;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

25
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/CreateAppealRequest.java

@ -0,0 +1,25 @@
package com.mydreamjob.candidatescore.governance.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 提交评分申诉
*/
@Data
public class CreateAppealRequest {
@NotBlank(message = "dimension is required")
private String dimension;
/** yyyy-MM */
@NotBlank(message = "period is required")
private String period;
/** 必填由服务层校验(返回 8604),此处不 @NotBlank */
private String reason;
@Size(max = 64, message = "idempotencyKey must not exceed 64 characters")
private String idempotencyKey;
}

28
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/SnapshotHistoryItem.java

@ -0,0 +1,28 @@
package com.mydreamjob.candidatescore.governance.dto;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 快照变更审计项(回滚前后完整变化,仅 ADMIN)
*/
@Data
@Builder
public class SnapshotHistoryItem {
private Long id;
private Long appealId;
private String action;
private String dimension;
private String period;
private BigDecimal beforeScore;
private BigDecimal afterScore;
private Long beforeVersion;
private Long afterVersion;
private String beforeRulesVersion;
private String afterRulesVersion;
private LocalDateTime createdAt;
}

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

@ -58,6 +58,14 @@ public enum ErrorCode {
OFFER_APPLICATION_NOT_ELIGIBLE(8106, "application is not eligible for offer"), OFFER_APPLICATION_NOT_ELIGIBLE(8106, "application is not eligible for offer"),
OFFER_REMINDER_DUPLICATE(8107, "duplicate offer reminder"), OFFER_REMINDER_DUPLICATE(8107, "duplicate offer reminder"),
OFFER_NOT_REMINDABLE(8108, "offer is not eligible for manual reminder"), OFFER_NOT_REMINDABLE(8108, "offer is not eligible for manual reminder"),
OFFER_COUNTER_OFFER_EXISTS(8109, "active counter-offer already exists for this offer"),
OFFER_COUNTER_OFFER_NOT_FOUND(8110, "counter-offer not found"),
OFFER_COUNTER_OFFER_NOT_PENDING(8111, "counter-offer is not in pending status"),
OFFER_NOT_WITHDRAWABLE(8112, "offer is not in a withdrawable status"),
OFFER_COUNTER_OFFER_EXPIRED(8113, "counter-offer has expired"),
OFFER_NOT_NEGOTIABLE(8114, "offer status does not allow counter-offer"),
OFFER_COMPARE_TOO_MANY(8115, "compare supports 2-5 offer ids"),
OFFER_COMPARE_NOT_OWNER(8116, "one or more offers do not belong to the current user"),
// 简历版本 82xx // 简历版本 82xx
RESUME_VERSION_NOT_FOUND(8201, "resume version not found"), RESUME_VERSION_NOT_FOUND(8201, "resume version not found"),
@ -72,6 +80,8 @@ public enum ErrorCode {
MATCH_SCORES_LOCKED(8302, "match already succeeded, scores are locked"), MATCH_SCORES_LOCKED(8302, "match already succeeded, scores are locked"),
MATCH_REQUIRED_FOR_CONVERSATION(8303, "direct conversation between company and candidate requires a matched application"), MATCH_REQUIRED_FOR_CONVERSATION(8303, "direct conversation between company and candidate requires a matched application"),
APPLICATION_NOT_MATCHED(8304, "application is not matched"), APPLICATION_NOT_MATCHED(8304, "application is not matched"),
// 站内信 8305
CONVERSATION_NOT_FOUND(8305, "conversation not found"),
// 评价 91xx // 评价 91xx
DUPLICATE_REVIEW(9101, "duplicate review"), DUPLICATE_REVIEW(9101, "duplicate review"),
@ -107,9 +117,55 @@ public enum ErrorCode {
INVALID_VERIFICATION_STATUS_TRANSITION(8504, "invalid verification status transition"), INVALID_VERIFICATION_STATUS_TRANSITION(8504, "invalid verification status transition"),
VERIFICATION_REJECT_REASON_REQUIRED(8505, "reject reason is required"), VERIFICATION_REJECT_REASON_REQUIRED(8505, "reject reason is required"),
// 评分治理 86xx
APPEAL_NOT_FOUND(8601, "appeal not found"),
APPEAL_DUPLICATE(8602, "active appeal already exists for the same dimension and period"),
APPEAL_ALREADY_DECIDED(8603, "appeal already decided"),
APPEAL_REASON_REQUIRED(8604, "appeal reason is required"),
APPEAL_ROLLBACK_LIMIT_EXCEEDED(8605, "annual rollback limit (3) exceeded"),
APPEAL_INVALID_TRANSITION(8606, "invalid appeal status transition"),
APPEAL_IDEMPOTENCY_CONFLICT(8607, "idempotency key conflict: request body differs"),
// 系统 9xxx // 系统 9xxx
SERVICE_UNAVAILABLE(9001, "service unavailable"), SERVICE_UNAVAILABLE(9001, "service unavailable"),
RATE_LIMITED(9002, "rate limited"); RATE_LIMITED(9002, "rate limited"),
// 隐私设置 9201
PRIVACY_SETTINGS_CONCURRENT_UPDATE(9201, "privacy settings concurrent update"),
// 账号注销 93xx
INVALID_DELETION_STATE(9301, "invalid deletion state"),
ACCOUNT_DELETION_PENDING(9302, "account deletion pending, business write is not allowed"),
ACCOUNT_DELETED(9303, "account has been deleted"),
ACCOUNT_DELETION_CONCURRENT_UPDATE(9304, "account deletion concurrent update"),
ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED(9305, "account deletion cancel deadline passed"),
LAST_ADMIN_CANNOT_DELETE(9306, "last active admin cannot be deleted"),
// 个人数据导出 94xx
DATA_EXPORT_TASK_NOT_FOUND(9401, "data export task not found"),
DATA_EXPORT_DUPLICATE(9402, "an unfinished data export task already exists"),
DATA_EXPORT_NOT_READY(9403, "data export file is not ready yet"),
DATA_EXPORT_EXPIRED(9404, "data export file has expired"),
DATA_EXPORT_FAILED(9405, "data export failed"),
DATA_EXPORT_FILE_NOT_FOUND(9406, "data export file not found"),
// 结构化面试评分 95xx
INTERVIEW_SCORE_CONCURRENT_UPDATE(9501, "interview score was modified concurrently"),
INTERVIEW_NOT_COMPLETED(9502, "interview is not completed, cannot score"),
INTERVIEW_SCORE_SUBMITTED(9503, "interview score already submitted"),
INTERVIEW_SCORE_NOT_FOUND(9504, "interview score not found"),
// 举报与屏蔽 96xx
REPORT_NOT_FOUND(9601, "report not found"),
CANNOT_REPORT_SELF(9602, "cannot report yourself"),
REPORT_DUPLICATE(9603, "duplicate pending report"),
REPORT_ALREADY_DECIDED(9604, "report already decided"),
REPORT_INVALID_TRANSITION(9605, "invalid report status transition"),
REPORT_IDEMPOTENCY_CONFLICT(9606, "idempotency key conflict: request body differs"),
REPORT_REASON_REQUIRED(9607, "report reason is required"),
CANNOT_BLOCK_SELF(9608, "cannot block yourself"),
REPORT_RATE_LIMITED(9609, "report rate limited"),
BLOCKED_SEND_PROHIBITED(9610, "message cannot be sent");
private final int code; private final int code;
private final String message; private final String message;

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

@ -68,11 +68,15 @@ public class GlobalExceptionHandler {
|| errorCode == ErrorCode.JOB_NOT_FOUND.getCode() || errorCode == ErrorCode.JOB_NOT_FOUND.getCode()
|| errorCode == ErrorCode.APPLICATION_NOT_FOUND.getCode() || errorCode == ErrorCode.APPLICATION_NOT_FOUND.getCode()
|| errorCode == ErrorCode.OFFER_NOT_FOUND.getCode() || errorCode == ErrorCode.OFFER_NOT_FOUND.getCode()
|| errorCode == ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND.getCode()
|| errorCode == ErrorCode.RESUME_VERSION_NOT_FOUND.getCode() || errorCode == ErrorCode.RESUME_VERSION_NOT_FOUND.getCode()
|| errorCode == ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND.getCode() || errorCode == ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND.getCode()
|| errorCode == ErrorCode.USER_NOT_FOUND.getCode() || errorCode == ErrorCode.USER_NOT_FOUND.getCode()
|| errorCode == ErrorCode.REVIEW_NOT_FOUND.getCode() || errorCode == ErrorCode.REVIEW_NOT_FOUND.getCode()
|| errorCode == ErrorCode.FEEDBACK_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; || errorCode == ErrorCode.FEEDBACK_NOT_FOUND.getCode()
|| errorCode == ErrorCode.CONVERSATION_NOT_FOUND.getCode()
|| errorCode == ErrorCode.APPEAL_NOT_FOUND.getCode()
|| errorCode == ErrorCode.DATA_EXPORT_TASK_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND;
// 冲突 → 409 // 冲突 → 409
if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode() if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode()
|| errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode() || errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode()
@ -86,6 +90,9 @@ public class GlobalExceptionHandler {
|| errorCode == ErrorCode.OFFER_EXPIRED.getCode() || errorCode == ErrorCode.OFFER_EXPIRED.getCode()
|| errorCode == ErrorCode.OFFER_NOT_EDITABLE.getCode() || errorCode == ErrorCode.OFFER_NOT_EDITABLE.getCode()
|| errorCode == ErrorCode.OFFER_REMINDER_DUPLICATE.getCode() || errorCode == ErrorCode.OFFER_REMINDER_DUPLICATE.getCode()
|| errorCode == ErrorCode.OFFER_COUNTER_OFFER_EXISTS.getCode()
|| errorCode == ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING.getCode()
|| errorCode == ErrorCode.OFFER_COUNTER_OFFER_EXPIRED.getCode()
|| errorCode == ErrorCode.DEFAULT_RESUME_DELETE_NOT_ALLOWED.getCode() || errorCode == ErrorCode.DEFAULT_RESUME_DELETE_NOT_ALLOWED.getCode()
|| errorCode == ErrorCode.RESUME_EXPORT_IN_PROGRESS.getCode() || errorCode == ErrorCode.RESUME_EXPORT_IN_PROGRESS.getCode()
|| errorCode == ErrorCode.DUPLICATE_DEFAULT_RESUME.getCode() || errorCode == ErrorCode.DUPLICATE_DEFAULT_RESUME.getCode()
@ -102,6 +109,9 @@ public class GlobalExceptionHandler {
// 越权 → 403(新增:企业-求职者建会话需匹配) // 越权 → 403(新增:企业-求职者建会话需匹配)
if (errorCode == ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION.getCode()) if (errorCode == ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION.getCode())
return HttpStatus.FORBIDDEN; return HttpStatus.FORBIDDEN;
// Offer 比较越权(包含非本人 Offer) → 403
if (errorCode == ErrorCode.OFFER_COMPARE_NOT_OWNER.getCode())
return HttpStatus.FORBIDDEN;
// 认证不存在 → 404 // 认证不存在 → 404
if (errorCode == ErrorCode.VERIFICATION_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; if (errorCode == ErrorCode.VERIFICATION_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND;
// 认证冲突 → 409 // 认证冲突 → 409
@ -112,6 +122,55 @@ public class GlobalExceptionHandler {
// 权重并发冲突 → 409 // 权重并发冲突 → 409
if (errorCode == ErrorCode.REVIEW_WEIGHT_CONCURRENT_UPDATE.getCode()) if (errorCode == ErrorCode.REVIEW_WEIGHT_CONCURRENT_UPDATE.getCode())
return HttpStatus.CONFLICT; return HttpStatus.CONFLICT;
// 隐私设置并发冲突 → 409
if (errorCode == ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE.getCode())
return HttpStatus.CONFLICT;
// 账号注销状态冲突 → 409(9301-9306)
if (errorCode == ErrorCode.INVALID_DELETION_STATE.getCode()
|| errorCode == ErrorCode.ACCOUNT_DELETION_PENDING.getCode()
|| errorCode == ErrorCode.ACCOUNT_DELETED.getCode()
|| errorCode == ErrorCode.ACCOUNT_DELETION_CONCURRENT_UPDATE.getCode()
|| errorCode == ErrorCode.ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED.getCode()
|| errorCode == ErrorCode.LAST_ADMIN_CANNOT_DELETE.getCode())
return HttpStatus.CONFLICT;
// 评分治理冲突 → 409
if (errorCode == ErrorCode.APPEAL_DUPLICATE.getCode()
|| errorCode == ErrorCode.APPEAL_ALREADY_DECIDED.getCode()
|| errorCode == ErrorCode.APPEAL_ROLLBACK_LIMIT_EXCEEDED.getCode()
|| errorCode == ErrorCode.APPEAL_INVALID_TRANSITION.getCode()
|| errorCode == ErrorCode.APPEAL_IDEMPOTENCY_CONFLICT.getCode())
return HttpStatus.CONFLICT;
// 个人数据导出 → 409(9402-9406,除 9401 已映射 404)
if (errorCode == ErrorCode.DATA_EXPORT_DUPLICATE.getCode()
|| errorCode == ErrorCode.DATA_EXPORT_NOT_READY.getCode()
|| errorCode == ErrorCode.DATA_EXPORT_EXPIRED.getCode()
|| errorCode == ErrorCode.DATA_EXPORT_FAILED.getCode()
|| errorCode == ErrorCode.DATA_EXPORT_FILE_NOT_FOUND.getCode())
return HttpStatus.CONFLICT;
// 结构化面试评分 → 404(9504 评分不存在)
if (errorCode == ErrorCode.INTERVIEW_SCORE_NOT_FOUND.getCode())
return HttpStatus.NOT_FOUND;
// 结构化面试评分 → 409(9501 并发 / 9502 未完成 / 9503 已提交)
if (errorCode == ErrorCode.INTERVIEW_SCORE_CONCURRENT_UPDATE.getCode()
|| errorCode == ErrorCode.INTERVIEW_NOT_COMPLETED.getCode()
|| errorCode == ErrorCode.INTERVIEW_SCORE_SUBMITTED.getCode())
return HttpStatus.CONFLICT;
// 举报与屏蔽:不存在 → 404
if (errorCode == ErrorCode.REPORT_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND;
// 举报与屏蔽:冲突/重复/非法流转 → 409
if (errorCode == ErrorCode.REPORT_DUPLICATE.getCode()
|| errorCode == ErrorCode.REPORT_ALREADY_DECIDED.getCode()
|| errorCode == ErrorCode.REPORT_INVALID_TRANSITION.getCode()
|| errorCode == ErrorCode.REPORT_IDEMPOTENCY_CONFLICT.getCode())
return HttpStatus.CONFLICT;
// 举报限流 → 429
if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode())
return HttpStatus.TOO_MANY_REQUESTS;
// Offer 进阶契约 → 400(不可撤销、不可还价、比较数量超限)
if (errorCode == ErrorCode.OFFER_NOT_WITHDRAWABLE.getCode()
|| errorCode == ErrorCode.OFFER_NOT_NEGOTIABLE.getCode()
|| errorCode == ErrorCode.OFFER_COMPARE_TOO_MANY.getCode())
return HttpStatus.BAD_REQUEST;
return HttpStatus.BAD_REQUEST; return HttpStatus.BAD_REQUEST;
} }

61
mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java

@ -0,0 +1,61 @@
package com.mydreamjob.competition;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.competition.dto.CompetitionRankingResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
/**
* 同岗位竞争力排名接口(V32)。
*
* <p>仅 STUDENT(已投递该岗位)可查看;身份只取 JWT,不接受 userId/studentUserId。
* 未登录 401;COMPANY/ADMIN/未投递 403/2002;岗位不存在或非 PUBLISHED 404/7001。</p>
*/
@Tag(name = "同岗位竞争力排名", description = "学生查看某岗位候选人池内的竞争力排名(匿名 Top 5)")
@RestController
@RequiredArgsConstructor
public class CompetitionRankingController {
private final CompetitionRankingService competitionRankingService;
@Operation(summary = "同岗位竞争力排名",
description = "仅 STUDENT 且已投递该岗位;返回本人排名/综合分/百分位/维度/缺口及匿名 Top 5。"
+ "COMPANY/ADMIN 或未投递学生返回 403;岗位不存在或非 PUBLISHED 返回 404。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "jobId 为空或 <=0"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 STUDENT 或学生未投递该岗位"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "岗位不存在或非 PUBLISHED")
})
@GetMapping("/jobs/{jobId}/competition-ranking")
public ResponseEntity<ApiResponse<CompetitionRankingResponse>> getRanking(
@PathVariable Long jobId, HttpServletRequest request) {
Long userId = resolveUserId(request);
CompetitionRankingResponse data = competitionRankingService.getRanking(userId, jobId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
return Long.valueOf(auth.getName());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

259
mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java

@ -0,0 +1,259 @@
package com.mydreamjob.competition;
import com.mydreamjob.application.Application;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScore;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.competition.dto.CompetitionRankingResponse;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 同岗位竞争力排名(V32)。
*
* <p>为已投递某 PUBLISHED 岗位的学生返回该岗位候选人池内的竞争力排名。
* 纯只读聚合,无新表、无 Flyway、不写任何业务表;综合分复用
* {@link CandidateScoringCalculator#overallScoreFromSnapshots}(真实公式),
* 不修改 percentile / 评分引擎 / 企业看板。</p>
*
* <p>排序在 Java 侧完成(不使用 MySQL NULLS LAST),无 N+1:cohort 投递一次批量、
* 候选人快照一次批量。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CompetitionRankingService {
private static final int TOP_N = 5;
private static final java.util.Set<String> EXCLUDED = java.util.Set.of("WITHDRAWN", "REJECTED");
private final UserRepository userRepository;
private final JobService jobService;
private final ApplicationRepository applicationRepository;
private final CandidateDimensionScoreRepository scoreRepository;
private final CandidateScoringCalculator calculator;
@Transactional(readOnly = true)
public CompetitionRankingResponse getRanking(Long studentUserId, Long jobId) {
if (jobId == null || jobId <= 0) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
assertStudentRole(studentUserId);
// 仅 PUBLISHED 岗位可查看未公开状态统一 404,避免暴露岗位候选人池
Job job = jobService.getJob(jobId);
if (!"PUBLISHED".equals(job.getStatus())) {
throw new BusinessException(ErrorCode.JOB_NOT_FOUND);
}
// 当前学生必须对本岗位存在「有效投递」(状态与 cohort 同口径:非 WITHDRAWN/REJECTED),
// 否则一律 403。禁止用"是否曾投递"的宽松判断,避免已退出候选人池仍返回 200。
boolean applied = applicationRepository
.existsByJobIdAndStudentUserIdAndStatusNotIn(jobId, studentUserId, EXCLUDED);
if (!applied) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return build(studentUserId, jobId);
}
private CompetitionRankingResponse build(Long meId, Long jobId) {
String asOf = yearMonth(LocalDate.now());
// 1. cohort 投递(排除 WITHDRAWN/REJECTED;唯一约束保证同岗位同学生仅一条)
List<Application> apps = applicationRepository
.findByJobIdAndStatusNotInOrderByCreatedAtAsc(jobId, EXCLUDED);
// 2. 批量读取候选人快照(period <= asOf),Java 侧取每人最新周期 —— 无 N+1
List<Long> candidateIds = apps.stream().map(Application::getStudentUserId)
.distinct().collect(Collectors.toList());
Map<Long, List<CandidateDimensionScore>> latestByStudent =
loadLatestSnapshots(candidateIds, asOf);
List<Row> rows = new ArrayList<>();
for (Application app : apps) {
Row r = new Row();
r.studentUserId = app.getStudentUserId();
r.createdAt = app.getCreatedAt();
List<CandidateDimensionScore> snaps = latestByStudent.getOrDefault(app.getStudentUserId(), List.of());
r.snapshotPeriod = snaps.isEmpty() ? null : latestPeriod(snaps);
r.overallScore = calculator.overallScoreFromSnapshots(snaps);
rows.add(r);
}
// 3. Java 排序:有分优先 → overallScore DESC → createdAt ASC → studentUserId ASC → 无分最后
rows.sort(Comparator
.comparing((Row r) -> r.overallScore, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(Row::getCreatedAt)
.thenComparing(Row::getStudentUserId));
// 4. 本人信息
Row me = rows.stream().filter(r -> r.studentUserId.equals(meId)).findFirst().orElse(null);
int cohortSize = rows.size();
Integer rank = null;
if (me != null && me.overallScore != null) {
rank = rows.indexOf(me) + 1;
}
BigDecimal percentile = computePercentile(rows, me);
List<CandidateDimensionScore> meSnaps = me == null
? List.of() : latestByStudent.getOrDefault(meId, List.of());
String myPeriod = me == null ? null : me.snapshotPeriod;
// 5. Top 5 匿名摘要
List<CompetitionRankingResponse.RankingSnapshot> top = new ArrayList<>();
int n = Math.min(TOP_N, rows.size());
for (int i = 0; i < n; i++) {
Row r = rows.get(i);
top.add(CompetitionRankingResponse.RankingSnapshot.builder()
.rank(i + 1).overallScore(r.overallScore).build());
}
return CompetitionRankingResponse.builder()
.jobId(jobId)
.rank(rank)
.cohortSize(cohortSize)
.overallScore(me == null ? null : me.overallScore)
.percentile(percentile)
.status(overviewStatus(meSnaps))
.asOfPeriod(asOf)
.mySnapshotPeriod(myPeriod)
.dimensions(buildDimensions(meSnaps))
.gaps(buildGaps(meSnaps))
.topCandidates(top)
.rulesVersion(CandidateScoringCalculator.RULES_VERSION)
.generatedAt(Instant.now())
.build();
}
// ── cohort 快照批量装载 ─────────────────────────────────────
private Map<Long, List<CandidateDimensionScore>> loadLatestSnapshots(List<Long> ids, String asOf) {
if (ids.isEmpty()) return Map.of();
List<CandidateDimensionScore> all = scoreRepository.findUpToPeriod(ids, asOf);
Map<Long, Map<String, List<CandidateDimensionScore>>> byStudentPeriod = new HashMap<>();
for (CandidateDimensionScore s : all) {
byStudentPeriod.computeIfAbsent(s.getStudentUserId(), k -> new HashMap<>())
.computeIfAbsent(s.getPeriod(), k -> new ArrayList<>()).add(s);
}
Map<Long, List<CandidateDimensionScore>> result = new HashMap<>();
for (Map.Entry<Long, Map<String, List<CandidateDimensionScore>>> e : byStudentPeriod.entrySet()) {
String latest = Collections.max(e.getValue().keySet());
result.put(e.getKey(), e.getValue().get(latest));
}
return result;
}
private String latestPeriod(List<CandidateDimensionScore> snaps) {
return snaps.stream().map(CandidateDimensionScore::getPeriod)
.max(String::compareTo).orElse(null);
}
// ── 百分位(严格复用企业看板公式)──────────────────────────
private BigDecimal computePercentile(List<Row> rows, Row me) {
if (me == null || me.overallScore == null) return null;
List<BigDecimal> scored = rows.stream().map(r -> r.overallScore)
.filter(Objects::nonNull).collect(Collectors.toList());
if (scored.size() < 2) return null;
long lower = scored.stream().filter(s -> s.compareTo(me.overallScore) < 0).count();
return BigDecimal.valueOf(lower)
.divide(BigDecimal.valueOf(scored.size() - 1), 10, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.setScale(2, RoundingMode.HALF_UP);
}
// ── 维度 / 状态 / 缺口(复用引擎语义,禁止另造公式)──────────
private List<CompetitionRankingResponse.DimensionItem> buildDimensions(List<CandidateDimensionScore> snaps) {
return snaps.stream()
.sorted(Comparator.comparing(CandidateDimensionScore::getDimension))
.map(s -> CompetitionRankingResponse.DimensionItem.builder()
.dimension(s.getDimension()).score(s.getScore()).status(s.getStatus())
.weight(s.getWeight() != null ? s.getWeight() : CandidateScoringCalculator.DEFAULT_WEIGHT)
.build())
.collect(Collectors.toList());
}
/**
* 基于维度状态的简单缺口;不新增缺口算法,不把 PENDING/INSUFFICIENT_DATA 转 0 分。
*/
private List<String> buildGaps(List<CandidateDimensionScore> snaps) {
List<String> gaps = new ArrayList<>();
long computable = snaps.stream()
.filter(s -> "OK".equals(s.getStatus()) || "ESTIMATED".equals(s.getStatus())).count();
if (computable == 0) {
gaps.add("评分数据不足:完善个人资料、简历、求职偏好及相关数据后即可获得竞争力评分");
return gaps;
}
for (CandidateDimensionScore s : snaps.stream()
.sorted(Comparator.comparing(CandidateDimensionScore::getDimension)).toList()) {
if ("PENDING".equals(s.getStatus())) {
gaps.add("维度「" + s.getDimension() + "」数据源尚未就绪,暂不计入综合分");
} else if ("INSUFFICIENT_DATA".equals(s.getStatus())) {
gaps.add("维度「" + s.getDimension() + "」数据不足,补充完整后将计入综合分");
}
}
return gaps;
}
private String overviewStatus(List<CandidateDimensionScore> snaps) {
if (snaps == null || snaps.isEmpty()) return "INSUFFICIENT_DATA";
long computable = snaps.stream()
.filter(s -> "OK".equals(s.getStatus()) || "ESTIMATED".equals(s.getStatus())).count();
if (computable == snaps.size()) return "OK";
if (computable > 0) return "PARTIAL";
boolean allPending = snaps.stream().allMatch(s -> "PENDING".equals(s.getStatus()));
return allPending ? "PENDING" : "INSUFFICIENT_DATA";
}
// ── 身份与工具 ─────────────────────────────────────────────
private void assertStudentRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private String yearMonth(LocalDate d) {
return String.format("%04d-%02d", d.getYear(), d.getMonthValue());
}
/** 内部排序载体(不参与响应序列化) */
static class Row {
Long studentUserId;
LocalDateTime createdAt;
BigDecimal overallScore;
String snapshotPeriod;
LocalDateTime getCreatedAt() { return createdAt; }
Long getStudentUserId() { return studentUserId; }
}
}

78
mydreamjob-server/src/main/java/com/mydreamjob/competition/dto/CompetitionRankingResponse.java

@ -0,0 +1,78 @@
package com.mydreamjob.competition.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
/**
* 同岗位竞争力排名响应(V32)。
*
* <p>仅 STUDENT(已投递该岗位)可查看;返回本人排名、综合分、维度与缺口,
* 以及匿名化 Top 5 摘要。<b>不返回 candidateId、不返回任何其他候选人身份字段。</b></p>
*/
@Data
@Builder
public class CompetitionRankingResponse {
private Long jobId;
/** 本人名次(完整 cohort 排序内,含无分者);本人无有效评分 → null */
@JsonInclude(JsonInclude.Include.ALWAYS)
private Integer rank;
/** cohort 总人数(含无评分候选人) */
private int cohortSize;
/** 本人综合分(复用 CandidateScoringCalculator 真实公式);无有效维度 → null */
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal overallScore;
/** 本人百分位;有分候选人数 &lt; 2 或本人无分 → null */
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal percentile;
/** 综合卡状态:OK / PARTIAL / INSUFFICIENT_DATA / PENDING(复用引擎语义) */
private String status;
/** 本次排名计算截止月份 yyyy-MM(当前月) */
private String asOfPeriod;
/** 当前学生实际使用的最新快照月份;无快照 → null */
@JsonInclude(JsonInclude.Include.ALWAYS)
private String mySnapshotPeriod;
/** 本人各维度评分与状态 */
private List<DimensionItem> dimensions;
/** 基于维度状态的简单缺口建议(数据不足 / 待补充 / 维度未完成) */
private List<String> gaps;
/** 匿名化 Top 5 摘要(仅 rank + overallScore,脱敏) */
private List<RankingSnapshot> topCandidates;
private String rulesVersion;
private Instant generatedAt;
@Data
@Builder
public static class DimensionItem {
private String dimension;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal score;
private String status;
private BigDecimal weight;
}
@Data
@Builder
public static class RankingSnapshot {
private Integer rank;
@JsonInclude(JsonInclude.Include.ALWAYS)
private BigDecimal overallScore;
}
}

14
mydreamjob-server/src/main/java/com/mydreamjob/config/AsyncConfig.java

@ -28,4 +28,18 @@ public class AsyncConfig {
executor.initialize(); executor.initialize();
return executor; return executor;
} }
@Bean(name = "dataExportExecutor")
public Executor dataExportExecutor(
@Value("${app.async.data-export.core-pool-size:2}") int corePoolSize,
@Value("${app.async.data-export.max-pool-size:4}") int maxPoolSize,
@Value("${app.async.data-export.queue-capacity:100}") int queueCapacity) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(corePoolSize);
executor.setMaxPoolSize(maxPoolSize);
executor.setQueueCapacity(queueCapacity);
executor.setThreadNamePrefix("data-export-");
executor.initialize();
return executor;
}
} }

32
mydreamjob-server/src/main/java/com/mydreamjob/config/OpenApiSecurityConfig.java

@ -0,0 +1,32 @@
package com.mydreamjob.config;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* OpenAPI / SpringDoc 安全方案配置。
*
* <p>定义全局 Bearer JWT 安全方案,供各接口通过
* {@code @SecurityRequirement(name = "bearerAuth")} 引用。
*/
@Configuration
@SecurityScheme(
name = "bearerAuth",
type = SecuritySchemeType.HTTP,
scheme = "bearer",
bearerFormat = "JWT",
description = "通过 /auth/login 获取的 Access Token,请求头格式:Authorization: Bearer &lt;token&gt;")
public class OpenApiSecurityConfig {
@Bean
public OpenAPI homepageOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("职来 API")
.version("v1"));
}
}

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

@ -115,6 +115,10 @@ public class SecurityConfig {
// 投递(需登录) // 投递(需登录)
.requestMatchers("/applications/**").authenticated() .requestMatchers("/applications/**").authenticated()
.requestMatchers("/company/applications").authenticated() .requestMatchers("/company/applications").authenticated()
// 企业候选人看板(需登录,COMPANY 角色,Service 层校验)
.requestMatchers("/company/candidates/**").authenticated()
// 企业评分运营看板(需登录,COMPANY/ADMIN 角色,Service 层校验)
.requestMatchers("/company/scoring/dashboard/**").authenticated()
// 面试(需登录) // 面试(需登录)
.requestMatchers("/interviews/**").authenticated() .requestMatchers("/interviews/**").authenticated()
// API 信息入口 // API 信息入口

14
mydreamjob-server/src/main/java/com/mydreamjob/config/WebMvcConfig.java

@ -1,12 +1,22 @@
package com.mydreamjob.config; package com.mydreamjob.config;
import com.mydreamjob.deletion.DeletionGuardInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/** /**
* Web MVC 配置 * Web MVC 配置:注册账号注销守卫拦截器。
*/ */
@Configuration @Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer { public class WebMvcConfig implements WebMvcConfigurer {
// 如需添加拦截器、路径样式等,在此配置
private final DeletionGuardInterceptor deletionGuardInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(deletionGuardInterceptor).addPathPatterns("/**");
}
} }

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

@ -5,6 +5,8 @@ import com.mydreamjob.auth.JwtTokenProvider;
import com.mydreamjob.auth.LoginRequest; import com.mydreamjob.auth.LoginRequest;
import com.mydreamjob.auth.SmsVerificationService; import com.mydreamjob.auth.SmsVerificationService;
import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@ -75,6 +77,14 @@ public class AuthController {
request.getRemoteAddr(), request.getRemoteAddr(),
request.getHeader("User-Agent")); request.getHeader("User-Agent"));
return ResponseEntity.ok(ApiResponse.ok(result, getRequestId(request))); return ResponseEntity.ok(ApiResponse.ok(result, getRequestId(request)));
} catch (BusinessException e) {
// 已注销手机号:保留 HTTP 409 / 业务 code=9303,交由全局异常处理器映射。
// 其余业务异常(如验证码错误、参数校验)维持既有 400/code=400 行为。
if (e.getErrorCodeInt() == ErrorCode.ACCOUNT_DELETED.getCode()) {
throw e;
}
return ResponseEntity.badRequest().body(
ApiResponse.error(400, e.getMessage(), getRequestId(request)));
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.badRequest().body( return ResponseEntity.badRequest().body(
ApiResponse.error(400, e.getMessage(), getRequestId(request))); ApiResponse.error(400, e.getMessage(), getRequestId(request)));

215
mydreamjob-server/src/main/java/com/mydreamjob/dataexport/DataExportService.java

@ -0,0 +1,215 @@
package com.mydreamjob.dataexport;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.dataexport.dto.CreateDataExportRequest;
import com.mydreamjob.dataexport.dto.DataExportTaskResponse;
import com.mydreamjob.dataexport.storage.PersonalDataExportStorageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.List;
import java.util.Optional;
/**
* 个人数据导出服务:创建/查询/下载任务,异步触发 worker,兼容账号注销状态。
*
* <p>身份由调用方从 JWT 解析后传入 userId,不接受客户端指定身份;任务只能属于本人。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DataExportService {
private final PersonalDataExportTaskRepository taskRepository;
private final PersonalDataExportWorker worker;
private final PersonalDataExportStorageService storageService;
private final com.mydreamjob.deletion.AccountDeletionService deletionService;
private final AuditService auditService;
private static final List<String> UNFINISHED = List.of(
PersonalDataExportTask.STATUS_PENDING, PersonalDataExportTask.STATUS_PROCESSING);
private static final int MIN_SIZE = 1;
private static final int MAX_SIZE = 100;
/** 创建导出任务(PENDING)并异步执行。 */
@Transactional
public DataExportTaskResponse create(Long userId, CreateDataExportRequest req) {
assertNotDeleting(userId);
// format 规范性校验:null 默认 ZIP;空字符串/非 ZIP 视为非法参数(400/1001)
String format = PersonalDataExportTask.FORMAT_ZIP;
if (req.getFormat() != null) {
String f = req.getFormat().trim();
if (f.isEmpty()) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (!PersonalDataExportTask.FORMAT_ZIP.equalsIgnoreCase(f)) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
}
String key = req.getIdempotencyKey() == null ? null : req.getIdempotencyKey().trim();
if (key != null && key.isEmpty()) {
key = null;
}
// 幂等重放:同 key 已有任务 → 直接返回原任务(format 仅支持 ZIP,不存在请求体差异冲突)
if (key != null) {
Optional<PersonalDataExportTask> existingByKey =
taskRepository.findFirstByUserIdAndIdempotencyKeyOrderByCreatedAtDesc(userId, key);
if (existingByKey.isPresent()) {
return DataExportTaskResponse.of(existingByKey.get());
}
}
// 已完成且未过期 → 直接返回已有任务(不重复创建)
Optional<PersonalDataExportTask> latestDone =
taskRepository.findFirstByUserIdAndStatusOrderByCreatedAtDesc(
userId, PersonalDataExportTask.STATUS_COMPLETED);
if (latestDone.isPresent()) {
PersonalDataExportTask done = latestDone.get();
if (isDownloadable(done)) {
return DataExportTaskResponse.of(done);
}
}
// 存在未完成任务 → 重复错误
if (taskRepository.existsByUserIdAndStatusIn(userId, UNFINISHED)) {
throw new BusinessException(ErrorCode.DATA_EXPORT_DUPLICATE);
}
PersonalDataExportTask task = PersonalDataExportTask.builder()
.userId(userId)
.status(PersonalDataExportTask.STATUS_PENDING)
.format(format)
.idempotencyKey(key)
.build();
task = taskRepository.save(task);
auditService.log(userId, "DATA_EXPORT_REQUESTED", "PERSONAL_DATA_EXPORT", task.getId(),
"taskId=" + task.getId() + "; userId=" + userId + "; format=" + task.getFormat()
+ "; status=" + task.getStatus() + "; sizeBytes=" + 0,
null, null, null);
triggerWorkerAfterCommit(task.getId());
return DataExportTaskResponse.of(task);
}
/** 查询当前用户导出任务分页列表。 */
@Transactional(readOnly = true)
public PageResponse<DataExportTaskResponse> list(Long userId, int page, int size, String status) {
assertNotCompleted(userId);
if (page < 0 || size < MIN_SIZE || size > MAX_SIZE) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "page/size out of range");
}
Pageable pageable = PageRequest.of(page, size);
Page<PersonalDataExportTask> result = (status == null || status.isBlank())
? taskRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable)
: taskRepository.findByUserIdAndStatusOrderByCreatedAtDesc(userId, status, pageable);
List<DataExportTaskResponse> items = result.stream()
.map(DataExportTaskResponse::of).toList();
return PageResponse.of(items, result.getTotalElements(), page, size);
}
/** 查询当前用户某个任务详情。 */
@Transactional(readOnly = true)
public DataExportTaskResponse get(Long userId, Long taskId) {
assertNotCompleted(userId);
PersonalDataExportTask task = ownedTask(userId, taskId);
return DataExportTaskResponse.of(task);
}
/**
* 下载已完成 ZIP。仅本人 + COMPLETED 且未过期;记录 downloadedAt 与审计。
* 文件不存在返回明确业务错误而非 500。
*/
@Transactional
public DownloadFile download(Long userId, Long taskId) {
assertNotCompleted(userId);
PersonalDataExportTask task = ownedTask(userId, taskId);
switch (task.getStatus()) {
case PersonalDataExportTask.STATUS_COMPLETED:
break;
case PersonalDataExportTask.STATUS_FAILED:
throw new BusinessException(ErrorCode.DATA_EXPORT_FAILED);
case PersonalDataExportTask.STATUS_EXPIRED:
case PersonalDataExportTask.STATUS_CANCELLED:
throw new BusinessException(ErrorCode.DATA_EXPORT_EXPIRED);
default:
throw new BusinessException(ErrorCode.DATA_EXPORT_NOT_READY);
}
if (task.getExpiresAt() != null && !task.getExpiresAt().isAfter(java.time.LocalDateTime.now())) {
throw new BusinessException(ErrorCode.DATA_EXPORT_EXPIRED);
}
if (task.getStorageKey() == null) {
throw new BusinessException(ErrorCode.DATA_EXPORT_FILE_NOT_FOUND);
}
byte[] content = storageService.read(task.getStorageKey());
if (task.getDownloadedAt() == null) {
task.setDownloadedAt(java.time.LocalDateTime.now());
taskRepository.save(task);
auditService.log(userId, "DATA_EXPORT_DOWNLOADED", "PERSONAL_DATA_EXPORT", task.getId(),
"taskId=" + task.getId() + "; userId=" + userId + "; format=" + task.getFormat()
+ "; status=" + task.getStatus() + "; sizeBytes="
+ (task.getSizeBytes() == null ? 0 : task.getSizeBytes()),
null, null, null);
}
return new DownloadFile(task.getId() + "-personal-data.zip", content);
}
private boolean isDownloadable(PersonalDataExportTask t) {
if (!PersonalDataExportTask.STATUS_COMPLETED.equals(t.getStatus()) || t.getStorageKey() == null) {
return false;
}
return t.getExpiresAt() == null || t.getExpiresAt().isAfter(java.time.LocalDateTime.now());
}
private PersonalDataExportTask ownedTask(Long userId, Long taskId) {
PersonalDataExportTask task = taskRepository.findById(taskId)
.orElseThrow(() -> new BusinessException(ErrorCode.DATA_EXPORT_TASK_NOT_FOUND));
if (!task.getUserId().equals(userId)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return task;
}
private void assertNotCompleted(Long userId) {
if (deletionService.effectiveStatus(userId)
== com.mydreamjob.deletion.DeletionStatus.COMPLETED) {
throw new BusinessException(ErrorCode.ACCOUNT_DELETED);
}
}
private void assertNotDeleting(Long userId) {
com.mydreamjob.deletion.DeletionStatus status = deletionService.effectiveStatus(userId);
if (status == com.mydreamjob.deletion.DeletionStatus.COMPLETED) {
throw new BusinessException(ErrorCode.ACCOUNT_DELETED);
}
if (status == com.mydreamjob.deletion.DeletionStatus.PENDING) {
throw new BusinessException(ErrorCode.ACCOUNT_DELETION_PENDING);
}
}
private void triggerWorkerAfterCommit(Long taskId) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
worker.execute(taskId);
}
});
} else {
worker.execute(taskId);
}
}
/** 下载文件载体。 */
public record DownloadFile(String filename, byte[] content) {
}
}

482
mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java

@ -0,0 +1,482 @@
package com.mydreamjob.dataexport;
import com.mydreamjob.application.Application;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.application.ApplicationStatusHistory;
import com.mydreamjob.application.ApplicationStatusHistoryRepository;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScore;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.candidatescore.governance.CandidateScoringAppeal;
import com.mydreamjob.candidatescore.governance.CandidateScoringAppealHistory;
import com.mydreamjob.candidatescore.governance.CandidateScoringAppealHistoryRepository;
import com.mydreamjob.candidatescore.governance.CandidateScoringAppealRepository;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.feedback.CandidateFeedback;
import com.mydreamjob.feedback.CandidateFeedbackRepository;
import com.mydreamjob.interview.Interview;
import com.mydreamjob.interview.InterviewRepository;
import com.mydreamjob.interview.InterviewStatusHistory;
import com.mydreamjob.interview.InterviewStatusHistoryRepository;
import com.mydreamjob.job.JobFavorite;
import com.mydreamjob.job.JobFavoriteRepository;
import com.mydreamjob.messaging.Conversation;
import com.mydreamjob.messaging.ConversationMember;
import com.mydreamjob.messaging.ConversationMemberRepository;
import com.mydreamjob.messaging.ConversationRepository;
import com.mydreamjob.messaging.Message;
import com.mydreamjob.messaging.MessageRepository;
import com.mydreamjob.notification.Notification;
import com.mydreamjob.notification.NotificationRepository;
import com.mydreamjob.offer.Offer;
import com.mydreamjob.offer.OfferRepository;
import com.mydreamjob.offer.OfferStatusHistory;
import com.mydreamjob.offer.OfferStatusHistoryRepository;
import com.mydreamjob.preference.StudentPreference;
import com.mydreamjob.preference.StudentPreferenceRepository;
import com.mydreamjob.profile.StudentProfile;
import com.mydreamjob.profile.StudentProfileRepository;
import com.mydreamjob.profile.storage.ResumeStorageService;
import com.mydreamjob.resume.ResumeVersion;
import com.mydreamjob.resume.ResumeVersionRepository;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 个人数据导出收集器:汇总当前用户自己的业务数据,用于打包 ZIP。
*
* <p>每个 section 生成 JSON 片段;resumes 同时附带用户自己上传的简历文件二进制。
* 严格执行脱敏:不导出 passwordHash、JWT、短信验证码、deletedPhoneHash、其他用户资料、内部审核字段。
* 会话对方只保留 participantId,不导出完整资料。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PersonalDataCollector {
private static final int MAX_ROWS = 10000;
private final UserRepository userRepository;
private final StudentProfileRepository studentProfileRepository;
private final StudentPreferenceRepository studentPreferenceRepository;
private final ResumeVersionRepository resumeVersionRepository;
private final ApplicationRepository applicationRepository;
private final ApplicationStatusHistoryRepository applicationStatusHistoryRepository;
private final InterviewRepository interviewRepository;
private final InterviewStatusHistoryRepository interviewStatusHistoryRepository;
private final OfferRepository offerRepository;
private final OfferStatusHistoryRepository offerStatusHistoryRepository;
private final CandidateFeedbackRepository candidateFeedbackRepository;
private final JobFavoriteRepository jobFavoriteRepository;
private final NotificationRepository notificationRepository;
private final ConversationMemberRepository conversationMemberRepository;
private final ConversationRepository conversationRepository;
private final MessageRepository messageRepository;
private final CandidateDimensionScoreRepository candidateDimensionScoreRepository;
private final CandidateScoringAppealRepository candidateScoringAppealRepository;
private final CandidateScoringAppealHistoryRepository candidateScoringAppealHistoryRepository;
private final CompanyRepository companyRepository;
private final ResumeStorageService resumeStorageService;
@Getter
public static class CollectResult {
private final Map<String, Object> sections = new LinkedHashMap<>();
private final Map<String, byte[]> binaries = new LinkedHashMap<>();
}
public CollectResult collect(Long userId) {
CollectResult r = new CollectResult();
r.getSections().put("profile", buildProfile(userId));
r.getSections().put("preferences", buildPreferences(userId));
buildResumes(r, userId);
buildApplications(r, userId);
buildInterviews(r, userId);
buildOffers(r, userId);
buildFeedback(r, userId);
buildFavorites(r, userId);
buildNotifications(r, userId);
buildConversationsAndMessages(r, userId);
buildScoring(r, userId);
return r;
}
// ── profile ─────────────────────────────────────────────
private Map<String, Object> buildProfile(Long userId) {
Map<String, Object> p = new LinkedHashMap<>();
userRepository.findById(userId).ifPresent(u -> {
p.put("userId", u.getId());
p.put("nickname", u.getNickname());
p.put("avatarUrl", u.getAvatarUrl());
p.put("email", u.getEmail());
p.put("role", u.getRole() == null ? null : u.getRole().name());
p.put("active", u.getActive());
p.put("verified", u.getVerified());
p.put("createdAt", u.getCreatedAt());
p.put("updatedAt", u.getUpdatedAt());
});
studentProfileRepository.findByUserId(userId).ifPresent(sp -> {
p.put("realName", sp.getRealName());
p.put("bio", sp.getBio());
p.put("location", sp.getLocation());
p.put("education", sp.getEducation());
p.put("school", sp.getSchool());
p.put("graduationYear", sp.getGraduationYear());
p.put("skills", sp.getSkillsJson());
p.put("studentProfileUpdatedAt", sp.getUpdatedAt());
});
return p;
}
// ── preferences ─────────────────────────────────────────
private Map<String, Object> buildPreferences(Long userId) {
Optional<StudentPreference> opt = studentPreferenceRepository.findByStudentUserId(userId);
if (opt.isEmpty()) {
return Map.of();
}
StudentPreference sp = opt.get();
Map<String, Object> m = new LinkedHashMap<>();
m.put("studentUserId", sp.getStudentUserId());
m.put("expectedSalaryMin", sp.getExpectedSalaryMin());
m.put("expectedSalaryMax", sp.getExpectedSalaryMax());
m.put("preferredCities", safeJson(sp.getPreferredCities()));
m.put("employmentTypes", safeJson(sp.getEmploymentTypes()));
m.put("preferredIndustries", safeJson(sp.getPreferredIndustries()));
m.put("preferredJobTypes", safeJson(sp.getPreferredJobTypes()));
m.put("remoteAllowed", sp.getRemoteAllowed());
m.put("createdAt", sp.getCreatedAt());
m.put("updatedAt", sp.getUpdatedAt());
return m;
}
// ── resumes ─────────────────────────────────────────────
private void buildResumes(CollectResult r, Long userId) {
List<Map<String, Object>> list = new ArrayList<>();
resumeVersionRepository.findByUserIdAndStatusOrderByVersionNoDesc(userId, ResumeVersion.STATUS_ACTIVE)
.forEach(v -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("resumeVersionId", v.getId());
m.put("name", v.getName());
m.put("versionNo", v.getVersionNo());
m.put("fileName", v.getFileName());
m.put("contentType", v.getContentType());
m.put("fileSize", v.getFileSize());
m.put("default", v.isDefault());
m.put("status", v.getStatus());
m.put("createdAt", v.getCreatedAt());
list.add(m);
// 附带用户自己上传的简历文件(相对文件名放入 resumes/)
byte[] bytes = safeRead(v.getFileStoragePath());
String entry = "resumes/" + v.getId() + "-" + safeFileName(v.getFileName());
if (entry.isEmpty() || bytes == null) {
return;
}
r.getBinaries().put(entry, bytes);
});
r.getSections().put("resumes", list);
}
// ── applications + timelines ────────────────────────────
private void buildApplications(CollectResult r, Long userId) {
List<Map<String, Object>> apps = new ArrayList<>();
List<Map<String, Object>> timelines = new ArrayList<>();
applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(userId)
.forEach(a -> {
apps.add(applicationView(a));
applicationStatusHistoryRepository
.findByApplicationIdOrderByCreatedAtAsc(a.getId())
.forEach(h -> timelines.add(applicationTimelineView(h)));
});
r.getSections().put("applications", apps);
r.getSections().put("application-timelines", timelines);
}
private Map<String, Object> applicationView(Application a) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("applicationId", a.getId());
m.put("jobId", a.getJobId());
m.put("status", a.getStatus());
m.put("coverLetter", a.getCoverLetter());
m.put("createdAt", a.getCreatedAt());
m.put("updatedAt", a.getUpdatedAt());
return m;
}
private Map<String, Object> applicationTimelineView(ApplicationStatusHistory h) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("applicationId", h.getApplicationId());
m.put("fromStatus", h.getFromStatus());
m.put("toStatus", h.getToStatus());
m.put("reason", h.getReason());
m.put("createdAt", h.getCreatedAt());
return m;
}
// ── interviews + timelines ──────────────────────────────
private void buildInterviews(CollectResult r, Long userId) {
List<Map<String, Object>> ivs = new ArrayList<>();
List<Map<String, Object>> timelines = new ArrayList<>();
interviewRepository.findByStudentUserIdOrderByStartAtDesc(userId, PageRequest.of(0, MAX_ROWS))
.forEach(iv -> addInterview(r, iv, ivs, timelines));
interviewRepository.findByCompanyUserIdOrderByStartAtDesc(userId, PageRequest.of(0, MAX_ROWS))
.forEach(iv -> addInterview(r, iv, ivs, timelines));
r.getSections().put("interviews", ivs);
r.getSections().put("interview-timelines", timelines);
}
private void addInterview(CollectResult r, Interview iv,
List<Map<String, Object>> ivs, List<Map<String, Object>> timelines) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("interviewId", iv.getId());
m.put("applicationId", iv.getApplicationId());
m.put("roundNo", iv.getRoundNo());
m.put("title", iv.getTitle());
m.put("interviewType", iv.getInterviewType());
m.put("startAt", iv.getStartAt());
m.put("endAt", iv.getEndAt());
m.put("timezone", iv.getTimezone());
m.put("interviewerName", iv.getInterviewerName());
m.put("address", iv.getAddress());
m.put("notes", iv.getNotes());
m.put("status", iv.getStatus());
ivs.add(m);
interviewStatusHistoryRepository.findByInterviewIdOrderByCreatedAtAsc(iv.getId())
.forEach(h -> timelines.add(interviewTimelineView(h)));
}
private Map<String, Object> interviewTimelineView(InterviewStatusHistory h) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("interviewId", h.getInterviewId());
m.put("fromStatus", h.getFromStatus());
m.put("toStatus", h.getToStatus());
m.put("reason", h.getReason());
m.put("createdAt", h.getCreatedAt());
return m;
}
// ── offers + timelines ──────────────────────────────────
private void buildOffers(CollectResult r, Long userId) {
List<Map<String, Object>> offers = new ArrayList<>();
List<Map<String, Object>> timelines = new ArrayList<>();
offerRepository.findByCandidateIdOrderByCreatedAtDesc(userId, PageRequest.of(0, MAX_ROWS))
.forEach(o -> addOffer(r, o, offers, timelines));
companyRepository.findByOwnerUserId(userId).ifPresent(c ->
offerRepository.findByCompanyIdOrderByCreatedAtDesc(c.getId(), PageRequest.of(0, MAX_ROWS))
.forEach(o -> addOffer(r, o, offers, timelines)));
r.getSections().put("offers", offers);
r.getSections().put("offer-timelines", timelines);
}
private void addOffer(CollectResult r, Offer o,
List<Map<String, Object>> offers, List<Map<String, Object>> timelines) {
if (!offersContains(offers, o.getId())) {
offers.add(offerView(o));
}
offerStatusHistoryRepository.findByOfferIdOrderByCreatedAtAsc(o.getId())
.forEach(h -> timelines.add(offerTimelineView(h)));
}
private boolean offersContains(List<Map<String, Object>> offers, Long id) {
return offers.stream().anyMatch(m -> m.get("offerId") != null && m.get("offerId").equals(id));
}
private Map<String, Object> offerView(Offer o) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("offerId", o.getId());
m.put("applicationId", o.getApplicationId());
m.put("jobId", o.getJobId());
m.put("companyId", o.getCompanyId());
m.put("title", o.getTitle());
m.put("salaryMin", o.getSalaryMin());
m.put("salaryMax", o.getSalaryMax());
m.put("salaryCurrency", o.getSalaryCurrency());
m.put("salaryPeriod", o.getSalaryPeriod());
m.put("workCity", o.getWorkCity());
m.put("employmentType", o.getEmploymentType());
m.put("probationMonths", o.getProbationMonths());
m.put("benefits", o.getBenefits());
m.put("status", o.getStatus());
m.put("expireAt", o.getExpireAt());
m.put("onboardingDate", o.getOnboardingDate());
m.put("createdAt", o.getCreatedAt());
m.put("updatedAt", o.getUpdatedAt());
return m;
}
private Map<String, Object> offerTimelineView(OfferStatusHistory h) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("offerId", h.getOfferId());
m.put("fromStatus", h.getFromStatus());
m.put("toStatus", h.getToStatus());
m.put("operatorRole", h.getOperatorRole());
m.put("reason", h.getReason());
m.put("createdAt", h.getCreatedAt());
return m;
}
// ── feedback ────────────────────────────────────────────
private void buildFeedback(CollectResult r, Long userId) {
List<Map<String, Object>> list = new ArrayList<>();
applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(userId)
.forEach(a -> candidateFeedbackRepository.findByApplicationId(a.getId())
.ifPresent(f -> list.add(feedbackView(f))));
r.getSections().put("feedback", list);
}
private Map<String, Object> feedbackView(CandidateFeedback f) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("applicationId", f.getApplicationId());
m.put("rating", f.getRating());
m.put("content", f.getContent());
m.put("tags", safeJson(f.getTags()));
m.put("createdAt", f.getCreatedAt());
return m;
}
// ── favorites ───────────────────────────────────────────
private void buildFavorites(CollectResult r, Long userId) {
List<Map<String, Object>> list = new ArrayList<>();
jobFavoriteRepository.findByStudentUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, MAX_ROWS))
.forEach(f -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("jobId", f.getJobId());
m.put("favoriteId", f.getId());
m.put("createdAt", f.getCreatedAt());
list.add(m);
});
r.getSections().put("favorites", list);
}
// ── notifications ───────────────────────────────────────
private void buildNotifications(CollectResult r, Long userId) {
List<Map<String, Object>> list = new ArrayList<>();
notificationRepository.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, MAX_ROWS))
.forEach(n -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("notificationId", n.getId());
m.put("type", n.getType());
m.put("title", n.getTitle());
m.put("content", n.getContent());
m.put("businessType", n.getBusinessType());
m.put("businessId", n.getBusinessId());
m.put("read", n.getRead());
m.put("readAt", n.getReadAt());
m.put("createdAt", n.getCreatedAt());
list.add(m);
});
r.getSections().put("notifications", list);
}
// ── conversations + messages ────────────────────────────
private void buildConversationsAndMessages(CollectResult r, Long userId) {
List<Map<String, Object>> convs = new ArrayList<>();
List<Map<String, Object>> msgs = new ArrayList<>();
for (ConversationMember member : conversationMemberRepository.findByUserId(userId)) {
conversationRepository.findById(member.getConversationId()).ifPresent(c -> {
Map<String, Object> mc = new LinkedHashMap<>();
mc.put("conversationId", c.getId());
mc.put("title", c.getTitle());
mc.put("type", c.getType());
mc.put("createdAt", c.getCreatedAt());
mc.put("updatedAt", c.getUpdatedAt());
convs.add(mc);
});
messageRepository.findByConversationIdOrderByCreatedAtDesc(
member.getConversationId(), PageRequest.of(0, MAX_ROWS))
.forEach(msg -> msgs.add(messageView(msg)));
}
r.getSections().put("conversations", convs);
r.getSections().put("messages", msgs);
}
private Map<String, Object> messageView(Message msg) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("conversationId", msg.getConversationId());
m.put("messageId", msg.getId());
// 对方只保留 participantId,不导出完整资料
m.put("senderParticipantId", "participant_" + msg.getSenderUserId());
m.put("content", msg.getContent());
m.put("createdAt", msg.getCreatedAt());
return m;
}
// ── scoring + appeals ───────────────────────────────────
private void buildScoring(CollectResult r, Long userId) {
List<Map<String, Object>> scores = new ArrayList<>();
candidateDimensionScoreRepository.findByStudentUserId(userId).forEach(s -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("dimension", s.getDimension());
m.put("period", s.getPeriod());
m.put("score", s.getScore());
m.put("status", s.getStatus());
m.put("subScores", safeJson(s.getSubScoresJson()));
m.put("weight", s.getWeight());
m.put("rulesVersion", s.getRulesVersion());
m.put("calculatedAt", s.getCalculatedAt());
scores.add(m);
});
r.getSections().put("candidate-scoring", scores);
List<Map<String, Object>> appeals = new ArrayList<>();
candidateScoringAppealRepository.findByCandidateUserIdOrderByCreatedAtDesc(
userId, PageRequest.of(0, MAX_ROWS))
.forEach(a -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("appealId", a.getId());
m.put("dimension", a.getDimension());
m.put("period", a.getPeriod());
m.put("status", a.getStatus());
m.put("reason", a.getReason());
m.put("decision", a.getDecision());
m.put("createdAt", a.getCreatedAt());
m.put("updatedAt", a.getUpdatedAt());
List<Map<String, Object>> hist = new ArrayList<>();
candidateScoringAppealHistoryRepository.findByAppealIdOrderByCreatedAtAsc(a.getId())
.forEach(h -> {
Map<String, Object> hm = new LinkedHashMap<>();
hm.put("fromStatus", h.getFromStatus());
hm.put("toStatus", h.getToStatus());
hm.put("actorRole", h.getActorRole());
hm.put("reason", h.getReason());
hm.put("createdAt", h.getCreatedAt());
hist.add(hm);
});
m.put("history", hist);
appeals.add(m);
});
r.getSections().put("scoring-appeals", appeals);
}
// ── helpers ─────────────────────────────────────────────
private byte[] safeRead(String storagePath) {
if (storagePath == null || storagePath.isBlank()) {
return null;
}
try {
return resumeStorageService.read(storagePath);
} catch (Exception e) {
log.debug("Skip resume file read: path={}", storagePath);
return null;
}
}
private Object safeJson(String json) {
return json;
}
private String safeFileName(String name) {
if (name == null) return "resume";
String n = name.replaceAll("[\\\\/:*?\"<>|\\s]+", "_");
return n.isEmpty() ? "resume" : n;
}
}

97
mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportBuilder.java

@ -0,0 +1,97 @@
package com.mydreamjob.dataexport;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* 个人数据导出 ZIP 打包器。
*
* <p>所有 JSON section 写入 {@code personal-data/<name>.json};用户简历文件写入
* {@code personal-data/resumes/<file>};manifest 描述导出范围与元数据。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PersonalDataExportBuilder {
private final ObjectMapper objectMapper;
private static final String ROOT = "personal-data/";
private static final DateTimeFormatter TS = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
public Result build(Long userId, PersonalDataCollector.CollectResult collect) {
List<String> entries = new ArrayList<>();
for (String section : collect.getSections().keySet()) {
entries.add(ROOT + section + ".json");
}
for (String binary : collect.getBinaries().keySet()) {
entries.add(ROOT + binary);
}
Map<String, Object> manifest = new LinkedHashMap<>();
manifest.put("schemaVersion", "1.0");
manifest.put("format", "ZIP");
manifest.put("requestedAt", LocalDateTime.now().format(TS));
manifest.put("fileCount", entries.size());
manifest.put("sections", new ArrayList<>(collect.getSections().keySet()));
byte[] manifestBytes = toJsonBytes("manifest", manifest);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos, StandardCharsets.UTF_8)) {
put(zos, ROOT + "manifest.json", manifestBytes);
for (Map.Entry<String, Object> e : collect.getSections().entrySet()) {
put(zos, ROOT + e.getKey() + ".json", toJsonBytes(e.getKey(), e.getValue()));
}
for (Map.Entry<String, byte[]> e : collect.getBinaries().entrySet()) {
put(zos, ROOT + e.getKey(), e.getValue());
}
} catch (Exception ex) {
throw new IllegalStateException("Failed to build personal data export zip", ex);
}
byte[] bytes = baos.toByteArray();
return new Result(bytes, sha256(bytes));
}
private void put(ZipOutputStream zos, String name, byte[] content) throws Exception {
zos.putNextEntry(new ZipEntry(name));
zos.write(content);
zos.closeEntry();
}
private byte[] toJsonBytes(String name, Object value) {
try {
return objectMapper.writeValueAsBytes(value);
} catch (Exception ex) {
log.warn("Cannot serialize export section {}: {}", name, ex.getMessage());
return "[]".getBytes(StandardCharsets.UTF_8);
}
}
private String sha256(byte[] data) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(data));
} catch (Exception e) {
return null;
}
}
public record Result(byte[] content, String checksumSha256) {
}
}

60
mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportCleanupService.java

@ -0,0 +1,60 @@
package com.mydreamjob.dataexport;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.dataexport.storage.PersonalDataExportStorageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 账号注销兼容清理:注销完成时删除用户导出文件,将未完成任务标记终态,写入审计。
*
* <p>只删除导出产物文件,不删除任何业务数据库数据。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PersonalDataExportCleanupService {
private final PersonalDataExportTaskRepository taskRepository;
private final PersonalDataExportStorageService storageService;
private final AuditService auditService;
private static final List<String> TERMINATED = List.of(
PersonalDataExportTask.STATUS_PENDING,
PersonalDataExportTask.STATUS_PROCESSING,
PersonalDataExportTask.STATUS_COMPLETED,
PersonalDataExportTask.STATUS_FAILED);
/**
* 账号注销完成(脱敏前/后调用均幂等):清理该用户的个人导出文件,并作废未完成任务。
*/
@Transactional
public void purgeForDeletedUser(Long userId) {
storageService.deleteAllForUser(userId);
List<PersonalDataExportTask> tasks =
taskRepository.findByUserIdOrderByCreatedAtDesc(
userId, org.springframework.data.domain.PageRequest.of(0, 10000))
.getContent();
for (PersonalDataExportTask t : tasks) {
if (TERMINATED.contains(t.getStatus())) {
try {
if (t.getStorageKey() != null) {
storageService.delete(t.getStorageKey());
}
t.setStatus(PersonalDataExportTask.STATUS_CANCELLED);
taskRepository.saveAndFlush(t);
auditService.log(userId, "DATA_EXPORT_CANCELLED", "PERSONAL_DATA_EXPORT",
t.getId(), "taskId=" + t.getId() + "; userId=" + userId
+ "; account deletion purge", null, null, null);
} catch (Exception e) {
log.warn("Failed to purge data export task on deletion: id={}, error={}",
t.getId(), e.getMessage());
}
}
}
}
}

106
mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java

@ -0,0 +1,106 @@
package com.mydreamjob.dataexport;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.dataexport.dto.CreateDataExportRequest;
import com.mydreamjob.dataexport.dto.DataExportTaskResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
/**
* 个人数据导出 Controller。
*
* <p>身份仅取自 JWT/SecurityContext;玩家只能操作自己的任务。下载凭证不可脱离 JWT,无任何公开 URL。
*/
@Tag(name = "个人数据导出", description = "为当前登录用户导出其个人业务数据(异步 ZIP)")
@RestController
@RequiredArgsConstructor
public class PersonalDataExportController {
private final DataExportService dataExportService;
@Operation(summary = "创建个人数据导出任务", description = "异步生成 ZIP;同 idempotencyKey 重放返回原任务;注销进行中禁止创建")
@PostMapping("/profile/me/data-export-tasks")
public ResponseEntity<ApiResponse<DataExportTaskResponse>> create(
@Valid @RequestBody(required = false) CreateDataExportRequest body,
HttpServletRequest request) {
Long userId = resolveUserId(request);
CreateDataExportRequest req = body == null ? new CreateDataExportRequest() : body;
return ResponseEntity.ok(ApiResponse.ok(
dataExportService.create(userId, req), getRequestId(request)));
}
@Operation(summary = "查询个人数据导出任务列表", description = "仅本人;按创建时间倒序分页")
@GetMapping("/profile/me/data-export-tasks")
public ResponseEntity<ApiResponse<PageResponse<DataExportTaskResponse>>> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String status,
HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(
dataExportService.list(userId, page, size, status), getRequestId(request)));
}
@Operation(summary = "查询个人数据导出任务详情", description = "仅本人;不暴露存储路径与内部字段")
@GetMapping("/profile/me/data-export-tasks/{taskId}")
public ResponseEntity<ApiResponse<DataExportTaskResponse>> get(
@PathVariable Long taskId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(
dataExportService.get(userId, taskId), getRequestId(request)));
}
@Operation(summary = "下载个人数据导出 ZIP", description = "仅本人且 COMPLETED 未过期可下载;attachment")
@GetMapping("/profile/me/data-export-tasks/{taskId}/download")
public ResponseEntity<byte[]> download(
@PathVariable Long taskId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
DataExportService.DownloadFile df = dataExportService.download(userId, taskId);
ContentDisposition cd = ContentDisposition.attachment()
.filename(df.filename(), StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("application/zip"))
.header(HttpHeaders.CONTENT_DISPOSITION, cd.toString())
.body(df.content());
}
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()
|| "anonymousUser".equals(String.valueOf(auth.getPrincipal()))) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
try {
return Long.valueOf(auth.getName());
} catch (NumberFormatException e) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

114
mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportTask.java

@ -0,0 +1,114 @@
package com.mydreamjob.dataexport;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 个人数据导出任务实体。
*
* <p>状态机:PENDING → PROCESSING → COMPLETED;PENDING/PROCESSING → FAILED;COMPLETED → EXPIRED。
* 同一用户同时最多一个 PENDING/PROCESSING 任务;idempotencyKey 用于创建幂等重放。
* storageKey 为存储相对路径,禁止对外暴露。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "personal_data_export_tasks")
@EntityListeners(AuditingEntityListener.class)
public class PersonalDataExportTask {
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_PROCESSING = "PROCESSING";
public static final String STATUS_COMPLETED = "COMPLETED";
public static final String STATUS_FAILED = "FAILED";
public static final String STATUS_EXPIRED = "EXPIRED";
public static final String STATUS_CANCELLED = "CANCELLED";
public static final String FORMAT_ZIP = "ZIP";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "status", nullable = false, length = 20)
@Builder.Default
private String status = STATUS_PENDING;
@Column(name = "format", nullable = false, length = 10)
private String format;
@Column(name = "idempotency_key", length = 64)
private String idempotencyKey;
@Column(name = "requested_at", nullable = false)
private LocalDateTime requestedAt;
@Column(name = "started_at")
private LocalDateTime startedAt;
@Column(name = "completed_at")
private LocalDateTime completedAt;
@Column(name = "expires_at")
private LocalDateTime expiresAt;
@Column(name = "downloaded_at")
private LocalDateTime downloadedAt;
@Column(name = "storage_key", length = 500)
private String storageKey;
@Column(name = "size_bytes")
private Long sizeBytes;
@Column(name = "checksum_sha256", length = 64)
private String checksumSha256;
@Column(name = "error_code", length = 50)
private String errorCode;
@Column(name = "error_message", length = 500)
private String errorMessage;
@Version
@Column(name = "version", nullable = false)
private Long version;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
void prePersist() {
LocalDateTime now = LocalDateTime.now();
if (createdAt == null) createdAt = now;
if (updatedAt == null) updatedAt = now;
if (requestedAt == null) requestedAt = now;
if (status == null) status = STATUS_PENDING;
if (format == null) format = FORMAT_ZIP;
if (version == null) version = 0L;
}
}

40
mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportTaskRepository.java

@ -0,0 +1,40 @@
package com.mydreamjob.dataexport;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.stereotype.Repository;
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
@Repository
public interface PersonalDataExportTaskRepository extends JpaRepository<PersonalDataExportTask, Long> {
/** 用户当前是否存在未完成任务(PENDING/PROCESSING) */
boolean existsByUserIdAndStatusIn(Long userId, List<String> statuses);
/** 按幂等键查询用户任务(用于创建幂等重放) */
Optional<PersonalDataExportTask> findFirstByUserIdAndIdempotencyKeyOrderByCreatedAtDesc(Long userId, String idempotencyKey);
/** 用户最近一个未完成任务(按创建时间倒序,用于返回原任务) */
Optional<PersonalDataExportTask> findFirstByUserIdAndStatusInOrderByCreatedAtDesc(Long userId, List<String> statuses);
/** 用户最近一个已完成任务 */
Optional<PersonalDataExportTask> findFirstByUserIdAndStatusOrderByCreatedAtDesc(Long userId, String status);
/** 用户导出任务分页,按创建时间倒序 */
Page<PersonalDataExportTask> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
/** 按状态过滤的分页查询 */
Page<PersonalDataExportTask> findByUserIdAndStatusOrderByCreatedAtDesc(Long userId, String status, Pageable pageable);
/** 查询已过期仍未标记的 COMPLETED 任务(文件有效期清理定时任务使用) */
List<PersonalDataExportTask> findByStatusAndExpiresAtBefore(String status, java.time.LocalDateTime cutoff);
/** Worker 抢占:领取一个待处理任务(按创建时间升序),乐观锁保证并发只有一个成功 */
@Lock(LockModeType.OPTIMISTIC)
Optional<PersonalDataExportTask> findFirstByStatusInOrderByCreatedAtAsc(List<String> statuses);
}

153
mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportWorker.java

@ -0,0 +1,153 @@
package com.mydreamjob.dataexport;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.dataexport.storage.PersonalDataExportStorageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 个人数据导出异步工作进程 + 过期清理定时任务。
*
* <p>状态流转:PENDING → PROCESSING → COMPLETED;任一步失败 → FAILED(记录安全 errorCode/errorMessage)。
* Worker 通过乐观锁抢占:只有第一个把 PENDING 提交为 PROCESSING 的调用胜出,并发时后者直接跳过。
* 所有业务异常在方法内捕获,不产生未处理异步异常。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PersonalDataExportWorker {
private final PersonalDataExportTaskRepository taskRepository;
private final PersonalDataCollector collector;
private final PersonalDataExportBuilder builder;
private final PersonalDataExportStorageService storageService;
private final AuditService auditService;
private final com.mydreamjob.deletion.AccountDeletionService deletionService;
@Value("${app.data-export.file-ttl-hours:24}")
private long fileTtlHours;
/**
* 处理单个导出任务(事务提交后调用)。乐观锁保证只执行一次。
*/
@Async("dataExportExecutor")
@Transactional
public void execute(Long taskId) {
PersonalDataExportTask task = taskRepository.findById(taskId).orElse(null);
if (task == null) {
log.warn("Data export task not found: {}", taskId);
return;
}
if (!PersonalDataExportTask.STATUS_PENDING.equals(task.getStatus())) {
log.info("Data export task not pending, skip: id={}, status={}", taskId, task.getStatus());
return;
}
// 注销兼容:用户在注销 PENDING/COMPLETED 期间不得生成新的个人数据导出文件 → 标记 FAILED
com.mydreamjob.deletion.DeletionStatus del = deletionService.effectiveStatus(task.getUserId());
if (del == com.mydreamjob.deletion.DeletionStatus.PENDING
|| del == com.mydreamjob.deletion.DeletionStatus.COMPLETED) {
log.info("Data export blocked by deletion state: taskId={}, status={}", taskId, del);
markFailed(task, new IllegalStateException("DATA_EXPORT_BLOCKED_ACCOUNT_DELETION"));
return;
}
// 抢占:PENDING → PROCESSING,@Version 乐观锁保证并发只有一个成功
task.setStatus(PersonalDataExportTask.STATUS_PROCESSING);
task.setStartedAt(LocalDateTime.now());
try {
taskRepository.saveAndFlush(task);
logAudit(task, "DATA_EXPORT_PROCESSING");
// ---- 实际导出打包 ----
factoryProcess(task);
} catch (ObjectOptimisticLockingFailureException e) {
log.info("Data export task already claimed concurrently: taskId={}", taskId);
} catch (Exception e) {
log.error("Personal data export failed: taskId={}, error={}", taskId, e.getMessage());
markFailed(task, e);
}
}
/**
* 结构化构建以隔离 @Async + @Transactional 的代理调用链(便于复测)。
*/
@Transactional
public void factoryProcess(PersonalDataExportTask task) {
PersonalDataCollector.CollectResult collected = collector.collect(task.getUserId());
PersonalDataExportBuilder.Result built = builder.build(task.getUserId(), collected);
String storageKey = storageService.store(task.getUserId(), task.getId(), built.content());
task.setStatus(PersonalDataExportTask.STATUS_COMPLETED);
task.setCompletedAt(LocalDateTime.now());
task.setExpiresAt(LocalDateTime.now().plusHours(fileTtlHours));
task.setStorageKey(storageKey);
task.setSizeBytes((long) built.content().length);
task.setChecksumSha256(built.checksumSha256());
task.setErrorCode(null);
task.setErrorMessage(null);
taskRepository.saveAndFlush(task);
logAudit(task, "DATA_EXPORT_COMPLETED");
log.info("Personal data export completed: taskId={}, size={}B", task.getId(), task.getSizeBytes());
}
/**
* 过期清理:COMPLETED 且已到 expiresAt 的任务 → EXPIRED 并删除本地导出文件(不删业务数据库数据)。
*/
@Transactional
@Scheduled(cron = "0 */5 * * * *")
public void expireCompletedTasks() {
LocalDateTime now = LocalDateTime.now();
List<PersonalDataExportTask> expired =
taskRepository.findByStatusAndExpiresAtBefore(PersonalDataExportTask.STATUS_COMPLETED, now);
for (PersonalDataExportTask task : expired) {
try {
if (task.getStorageKey() != null) {
storageService.delete(task.getStorageKey());
}
task.setStatus(PersonalDataExportTask.STATUS_EXPIRED);
taskRepository.saveAndFlush(task);
logAudit(task, "DATA_EXPORT_EXPIRED");
} catch (Exception e) {
log.warn("Failed to expire data export task: id={}, error={}", task.getId(), e.getMessage());
}
}
}
private void markFailed(PersonalDataExportTask task, Throwable e) {
task.setStatus(PersonalDataExportTask.STATUS_FAILED);
task.setCompletedAt(LocalDateTime.now());
task.setErrorCode(safeErrorCode(e));
task.setErrorMessage(safeErrorMessage(e));
try {
taskRepository.saveAndFlush(task);
} catch (Exception saveEx) {
log.error("Cannot persist failed data export task: id={}", task.getId(), saveEx);
}
logAudit(task, "DATA_EXPORT_FAILED");
}
private String safeErrorCode(Throwable e) {
String name = e == null ? null : e.getClass().getSimpleName();
return name == null ? "DATA_EXPORT_FAILED" : name.substring(0, Math.min(name.length(), 50));
}
private String safeErrorMessage(Throwable e) {
String msg = e == null || e.getMessage() == null ? "data export failed" : e.getMessage();
return msg.length() > 500 ? msg.substring(0, 500) : msg;
}
private void logAudit(PersonalDataExportTask task, String action) {
auditService.log(task.getUserId(), action, "PERSONAL_DATA_EXPORT", task.getId(),
"taskId=" + task.getId() + "; userId=" + task.getUserId()
+ "; format=" + task.getFormat() + "; status=" + task.getStatus()
+ (task.getSizeBytes() != null ? "; sizeBytes=" + task.getSizeBytes() : ""),
null, null, null);
}
}

19
mydreamjob-server/src/main/java/com/mydreamjob/dataexport/dto/CreateDataExportRequest.java

@ -0,0 +1,19 @@
package com.mydreamjob.dataexport.dto;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 创建个人数据导出任务请求体。
*
* <p>format 仅允许 ZIP(规范性校验在服务层完成,非法值返回 400/1001);idempotencyKey 可选,
* 用于创建幂等重放(最大 64 字符)。
*/
@Data
public class CreateDataExportRequest {
private String format;
@Size(max = 64, message = "idempotencyKey max length is 64")
private String idempotencyKey;
}

46
mydreamjob-server/src/main/java/com/mydreamjob/dataexport/dto/DataExportTaskResponse.java

@ -0,0 +1,46 @@
package com.mydreamjob.dataexport.dto;
import com.mydreamjob.dataexport.PersonalDataExportTask;
import lombok.Getter;
import java.time.LocalDateTime;
/**
* 个人数据导出任务响应视图。
*
* <p>不暴露 storageKey、服务器绝对路径、内部异常堆栈、数据库字段等敏感信息。
*/
@Getter
public class DataExportTaskResponse {
private final Long taskId;
private final String status;
private final String format;
private final LocalDateTime requestedAt;
private final LocalDateTime startedAt;
private final LocalDateTime completedAt;
private final LocalDateTime expiresAt;
private final Long sizeBytes;
private final boolean downloadAvailable;
private final String errorCode;
private DataExportTaskResponse(PersonalDataExportTask t) {
this.taskId = t.getId();
this.status = t.getStatus();
this.format = t.getFormat();
this.requestedAt = t.getRequestedAt();
this.startedAt = t.getStartedAt();
this.completedAt = t.getCompletedAt();
this.expiresAt = t.getExpiresAt();
this.sizeBytes = t.getSizeBytes();
this.errorCode = t.getErrorCode();
boolean ready = PersonalDataExportTask.STATUS_COMPLETED.equals(t.getStatus())
&& t.getStorageKey() != null;
this.downloadAvailable = ready && (t.getExpiresAt() == null
|| t.getExpiresAt().isAfter(LocalDateTime.now()));
}
public static DataExportTaskResponse of(PersonalDataExportTask t) {
return new DataExportTaskResponse(t);
}
}

112
mydreamjob-server/src/main/java/com/mydreamjob/dataexport/storage/LocalPersonalDataExportStorageService.java

@ -0,0 +1,112 @@
package com.mydreamjob.dataexport.storage;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.stream.Stream;
/**
* 个人数据导出产物本地存储。
*
* <p>目录通过 {@code app.data-export.dir} 配置(默认 {@code ./data-exports}),按用户分子目录。
* 仅保存相对路径 {@code userId/taskId.zip},禁止路径穿越与物理路径外泄。
*/
@Slf4j
@Component
public class LocalPersonalDataExportStorageService implements PersonalDataExportStorageService {
@Value("${app.data-export.dir:./data-exports}")
private String exportDir;
private Path basePath;
@PostConstruct
void init() {
basePath = Paths.get(exportDir).toAbsolutePath().normalize();
try {
Files.createDirectories(basePath);
} catch (IOException e) {
throw new RuntimeException("Cannot create data export directory: " + basePath, e);
}
log.info("Personal data export storage initialized at {}", basePath);
}
@Override
public String store(Long userId, Long taskId, byte[] content) {
String relative = userId + "/" + taskId + ".zip";
Path target = basePath.resolve(relative).normalize();
if (!target.startsWith(basePath)) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
try {
Files.createDirectories(target.getParent());
try (InputStream in = new java.io.ByteArrayInputStream(content)) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException e) {
throw new RuntimeException("Failed to store data export file", e);
}
return relative;
}
@Override
public byte[] read(String storageKey) {
Path target = resolve(storageKey);
if (!Files.exists(target) || !Files.isRegularFile(target)) {
throw new BusinessException(ErrorCode.DATA_EXPORT_FILE_NOT_FOUND);
}
try {
return Files.readAllBytes(target);
} catch (IOException e) {
throw new RuntimeException("Failed to read data export file", e);
}
}
@Override
public void delete(String storageKey) {
try {
Path target = resolve(storageKey);
Files.deleteIfExists(target);
} catch (IOException e) {
log.warn("Failed to delete data export file: {}", storageKey, e);
}
}
@Override
public void deleteAllForUser(Long userId) {
Path userDir = basePath.resolve(String.valueOf(userId)).normalize();
if (!userDir.startsWith(basePath) || !Files.isDirectory(userDir)) {
return;
}
try (Stream<Path> paths = Files.walk(userDir)) {
paths.filter(Files::isRegularFile).forEach(p -> {
try {
Files.deleteIfExists(p);
} catch (IOException e) {
log.warn("Failed to delete user data export file: {}", p, e);
}
});
Files.deleteIfExists(userDir);
} catch (IOException e) {
log.warn("Failed to clean user data export dir: userId={}", userId, e);
}
}
private Path resolve(String storageKey) {
Path target = basePath.resolve(storageKey).normalize();
if (!target.startsWith(basePath)) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
return target;
}
}

27
mydreamjob-server/src/main/java/com/mydreamjob/dataexport/storage/PersonalDataExportStorageService.java

@ -0,0 +1,27 @@
package com.mydreamjob.dataexport.storage;
/**
* 个人数据导出产物存储:独立目录(与简历原始存储分离),仅保存相对路径,禁止暴露物理路径。
*/
public interface PersonalDataExportStorageService {
/**
* 存储导出 ZIP,返回存储后的相对路径(非真实物理路径)。
*/
String store(Long userId, Long taskId, byte[] content);
/**
* 根据相对路径读取导出产物字节;文件不存在应抛出业务错误而非 500。
*/
byte[] read(String storageKey);
/**
* 删除单个导出产物(文件过期或账号注销时调用)。
*/
void delete(String storageKey);
/**
* 删除某用户全部导出产物(账号注销完成时调用)。
*/
void deleteAllForUser(Long userId);
}

77
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletion.java

@ -0,0 +1,77 @@
package com.mydreamjob.deletion;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 账号注销实体。
*
* <p>每个用户至多一行(user_id 唯一)。通过 {@link Version} 乐观锁处理并发状态迁移,
* 保证定时任务与懒转换并发时只有一个事务能完成状态迁移。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "account_deletions")
@EntityListeners(AuditingEntityListener.class)
public class AccountDeletion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false, unique = true)
private Long userId;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
@Builder.Default
private DeletionStatus status = DeletionStatus.NONE;
@Column(name = "requested_at")
private LocalDateTime requestedAt;
@Column(name = "effective_at")
private LocalDateTime effectiveAt;
@Column(name = "cancelled_at")
private LocalDateTime cancelledAt;
@Column(name = "completed_at")
private LocalDateTime completedAt;
@Version
@Column(name = "version", nullable = false)
private Long version;
/** 原手机号的 HMAC-SHA256 摘要(服务端密钥计算),用于禁止原手机号重新注册;不保存明文。 */
@Column(name = "deleted_phone_hash", length = 128, unique = true)
private String deletedPhoneHash;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

112
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java

@ -0,0 +1,112 @@
package com.mydreamjob.deletion;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.dataexport.PersonalDataExportCleanupService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* 注销完成工人(独立事务)。
*
* <p>将到期的 PENDING 一次性转为 COMPLETED 并执行用户 PII 脱敏和审计。
* 通过 REQUIRES_NEW 独立事务 + @Version 乐观锁,保证定时任务与懒转换并发时
* 只有一个事务完成状态迁移;并发冲突时另一方直接返回 false 且不影响外层请求。幂等安全。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AccountDeletionCompletionWorker {
private static final String PHONE_PREFIX = "deleted_";
private final AccountDeletionRepository accountDeletionRepository;
private final UserRepository userRepository;
private final AuditService auditService;
private final AccountDeletionPhoneHasher phoneHasher;
private final PersonalDataExportCleanupService dataExportCleanupService;
/**
* 若用户处于 PENDING 且已到期,则执行完成态迁移 + 脱敏 + 审计。
*
* @return 本调用是否实际完成了迁移(去重判据);未到期/非 PENDING/已被它事务完成则返回 false
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean completeIfExpired(Long userId) {
AccountDeletion rec = accountDeletionRepository.findByUserId(userId).orElse(null);
if (rec == null || rec.getStatus() != DeletionStatus.PENDING) {
return false;
}
LocalDateTime now = LocalDateTime.now();
if (rec.getEffectiveAt() == null || rec.getEffectiveAt().isAfter(now)) {
return false;
}
try {
// 先记录原手机号墓碑(HMAC),再脱敏;幂等:已脱敏则不会再进入
beforeDesensitize(rec);
rec.setStatus(DeletionStatus.COMPLETED);
rec.setCompletedAt(now);
accountDeletionRepository.saveAndFlush(rec); // @Version 门控:并发时后到者抛异常
desensitize(rec.getUserId());
// 注销完成:清理该用户的个人导出文件并作废未完成任务
dataExportCleanupService.purgeForDeletedUser(rec.getUserId());
// 完成 + 脱敏审计各写一次
auditService.log(userId, "ACCOUNT_DELETION_COMPLETED", "ACCOUNT_DELETION",
rec.getId(), "account deletion completed; userId=" + userId
+ "; PENDING->COMPLETED", null, null, null);
auditService.log(userId, "ACCOUNT_DATA_DESENSITIZED", "USER",
userId, "user pii desensitized; userId=" + userId, null, null, null);
return true;
} catch (ObjectOptimisticLockingFailureException e) {
log.info("Deletion already completed concurrently for userId={}", userId);
return false;
}
}
/**
* 脱敏前:用服务端密钥对原手机号计算 HMAC-SHA256 摘要并写入注销记录,
* 作为"原手机号禁止重新注册"的墓碑。只在首次完成时执行,幂等安全。
*/
private void beforeDesensitize(AccountDeletion rec) {
User u = userRepository.findById(rec.getUserId()).orElse(null);
if (u == null) {
return;
}
String originalPhone = u.getPhone();
if (originalPhone == null || originalPhone.isBlank()
|| originalPhone.startsWith(PHONE_PREFIX)) {
return;
}
rec.setDeletedPhoneHash(phoneHasher.hash(originalPhone));
}
/**
* 对 users 表做 PII 脱敏(幂等):phone 改为 deleted_{id} 保证唯一,
* 其余敏感字段置空,is_active=false。不物理删除任何业务数据。
*/
private void desensitize(Long userId) {
User u = userRepository.findById(userId).orElse(null);
if (u == null) {
return;
}
// 幂等:已脱敏则跳过
if (Boolean.FALSE.equals(u.getActive()) && u.getPhone() != null
&& u.getPhone().startsWith(PHONE_PREFIX)) {
return;
}
u.setPhone(PHONE_PREFIX + userId);
u.setEmail(null);
u.setNickname("deleted");
u.setAvatarUrl(null);
u.setPasswordHash(null);
u.setActive(false);
userRepository.saveAndFlush(u);
}
}

131
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java

@ -0,0 +1,131 @@
package com.mydreamjob.deletion;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.deletion.dto.AccountDeletionResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "账号注销", description = "当前用户账号注销申请、状态查询与取消(所有登录角色均可操作自己的账号)")
@RestController
@RequestMapping("/profile/me/deletion")
@RequiredArgsConstructor
public class AccountDeletionController {
private final AccountDeletionService accountDeletionService;
@Operation(
summary = "申请注销当前账号",
description = "当前登录用户申请注销。NONE → PENDING;PENDING 重复提交返回 200 当前状态(幂等,不重复写审计);"
+ "COMPLETED 再次申请返回 409/9301。身份仅从 JWT/SecurityContext 获取,不接受 userId 等身份参数。",
security = @SecurityRequirement(name = "bearerAuth"),
parameters = {})
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "成功;返回当前注销状态(NONE/PENDING);message=ok",
content = @Content(
mediaType = "application/json",
schema = @Schema(ref = "#/components/schemas/ApiResponseAccountDeletionResponse"))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "401",
description = "未登录、Token 缺失/非法/过期;code=401、message=not authenticated",
content = @Content(mediaType = "application/json")),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "403",
description = "访问被拒绝;code=2002、message=forbidden(当前未使用),保留契约",
content = @Content(mediaType = "application/json")),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "409",
description = "状态冲突:9301(COMPLETED 再申请)、9306(最后一个有效 ADMIN 不能注销)",
content = @Content(mediaType = "application/json"))
})
@PostMapping
public ResponseEntity<ApiResponse<AccountDeletionResponse>> initiate(HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(
accountDeletionService.initiate(userId), getRequestId(request)));
}
@Operation(
summary = "查询当前账号注销状态",
description = "查询当前登录用户注销状态。无记录时返回默认对象(NONE/version=0),且不自动创建数据库记录;"
+ "身份仅从 JWT/SecurityContext 获取,不接受 userId 等身份参数。",
security = @SecurityRequirement(name = "bearerAuth"),
parameters = {})
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "成功;返回注销状态对象(无记录时为默认 NONE);message=ok",
content = @Content(
mediaType = "application/json",
schema = @Schema(ref = "#/components/schemas/ApiResponseAccountDeletionResponse"))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "401",
description = "未登录、Token 缺失/非法/过期;code=401、message=not authenticated",
content = @Content(mediaType = "application/json"))
})
@GetMapping
public ResponseEntity<ApiResponse<AccountDeletionResponse>> getStatus(HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(
accountDeletionService.getCurrentStatus(userId), getRequestId(request)));
}
@Operation(
summary = "取消当前账号注销",
description = "仅 PENDING 且未超过 effectiveAt 时可取消(PENDING → NONE);"
+ "非 PENDING 重复取消返回当前状态保持幂等;已超过冷静期返回 409/9305;COMPLETED 不得恢复(返回 409/9301)。"
+ "身份仅从 JWT/SecurityContext 获取,不接受 userId 等身份参数。",
security = @SecurityRequirement(name = "bearerAuth"),
parameters = {})
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "成功;返回取消后的注销状态(NONE 或无记录默认);message=ok",
content = @Content(
mediaType = "application/json",
schema = @Schema(ref = "#/components/schemas/ApiResponseAccountDeletionResponse"))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "401",
description = "未登录、Token 缺失/非法/过期;code=401、message=not authenticated",
content = @Content(mediaType = "application/json")),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "409",
description = "状态冲突:9305(已超过冷静期)、9301(COMPLETED 不得恢复)",
content = @Content(mediaType = "application/json"))
})
@PostMapping("/cancel")
public ResponseEntity<ApiResponse<AccountDeletionResponse>> cancel(HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(
accountDeletionService.cancel(userId), getRequestId(request)));
}
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
return Long.valueOf(auth.getName());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

43
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionPhoneHasher.java

@ -0,0 +1,43 @@
package com.mydreamjob.deletion;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
/**
* 账号注销手机号墓碑哈希器。
*
* <p>使用服务端密钥(app.deletion.phone-hash-secret)对原手机号做 HMAC-SHA256,
* 仅保存摘要用于禁止原手机号重新注册;不保存明文手机号,不写入审计日志。
* HMAC 结果不落明文、不可逆,同一手机号 + 同一密钥产生稳定摘要,可用于精确命中查询。
*/
@Component
public class AccountDeletionPhoneHasher {
private static final String HMAC_ALGO = "HmacSHA256";
private final String secret;
public AccountDeletionPhoneHasher(
@Value("${app.deletion.phone-hash-secret:}") String secret) {
this.secret = secret;
}
/** 计算手机号的 HMAC-SHA256 十六进制摘要。 */
public String hash(String value) {
try {
Mac mac = Mac.getInstance(HMAC_ALGO);
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGO));
byte[] out = mac.doFinal(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(out);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new IllegalStateException("Failed to hash phone for deletion tombstone", e);
}
}
}

29
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionReadService.java

@ -0,0 +1,29 @@
package com.mydreamjob.deletion;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* 账号注销只读查询服务。
*
* <p>懒转换(如 {@link AccountDeletionService#getCurrentStatus})在外层事务中读到旧的 PENDING,
* 完成迁移的独立事务已提交新的 COMPLETED。MySQL 默认 REPEATABLE READ 下单连接仍可能读到旧快照,
* entityManager.clear() 无法解决。因此在此使用独立的 {@code REQUIRES_NEW + readOnly} 事务重读最新记录,
* 避免返回过期的 PENDING。
*/
@Service
@RequiredArgsConstructor
public class AccountDeletionReadService {
private final AccountDeletionRepository accountDeletionRepository;
/**
* 在全新事务(新快照)中按用户查询最新注销记录。
*/
@Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true)
public AccountDeletion findLatestByUserId(Long userId) {
return accountDeletionRepository.findByUserId(userId).orElse(null);
}
}

28
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionRepository.java

@ -0,0 +1,28 @@
package com.mydreamjob.deletion;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 账号注销仓储。
*/
@Repository
public interface AccountDeletionRepository extends JpaRepository<AccountDeletion, Long> {
Optional<AccountDeletion> findByUserId(Long userId);
/**
* 按原手机号 HMAC 摘要精确命中(用于禁止原手机号重新注册)。
*/
Optional<AccountDeletion> findByDeletedPhoneHash(String deletedPhoneHash);
/**
* 查询所有已进入冷静期且已到生效时间的记录(供定时任务扫描)。
*/
List<AccountDeletion> findByStatusAndEffectiveAtLessThanEqual(DeletionStatus status, LocalDateTime now);
}

223
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionService.java

@ -0,0 +1,223 @@
package com.mydreamjob.deletion;
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.deletion.dto.AccountDeletionResponse;
import jakarta.persistence.EntityManager;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* 账号注销服务(状态机编排)。
*
* <p>身份由调用方从 JWT/SecurityContext 解析后传入 userId,不接受客户端指定身份参数。
* 触发到期的懒转换通过 {@link AccountDeletionCompletionWorker} 的独立事务完成,保证并发安全与幂等。
*/
@Slf4j
@Service
public class AccountDeletionService {
private final AccountDeletionRepository accountDeletionRepository;
private final UserRepository userRepository;
private final AuditService auditService;
private final AccountDeletionCompletionWorker completionWorker;
private final AccountDeletionReadService readService;
private final EntityManager entityManager;
private final long coolingDays;
public AccountDeletionService(
AccountDeletionRepository accountDeletionRepository,
UserRepository userRepository,
AuditService auditService,
AccountDeletionCompletionWorker completionWorker,
AccountDeletionReadService readService,
EntityManager entityManager,
@Value("${app.deletion.cooling-days:30}") long coolingDays) {
this.accountDeletionRepository = accountDeletionRepository;
this.userRepository = userRepository;
this.auditService = auditService;
this.completionWorker = completionWorker;
this.readService = readService;
this.entityManager = entityManager;
this.coolingDays = coolingDays;
}
/**
* 查询当前用户注销状态;无记录返回默认 NONE(version=0),不自动插入数据库。
* 懒转换:若处于 PENDING 且已到期,先完成迁移再返回 COMPLETED。
*
* <p>懒转换在 {@link AccountDeletionCompletionWorker} 的 REQUIRES_NEW 独立事务中完成并提交。
* 外层事务(MySQL 默认 REPEATABLE READ)在同一条连接上仍可能读到旧的 PENDING 快照,
* entityManager.clear() 无法解决。故当触发懒转换时,在此方法内另起独立只读事务重读最新记录,
* 确保首次查询即返回 status=COMPLETED、completedAt、version(禁止返回过期的 PENDING)。
*/
@Transactional
public AccountDeletionResponse getCurrentStatus(Long userId) {
userOrThrow(userId);
// 触发懒转换(若 PENDING 已到期,则在 REQUIRES_NEW 独立事务中完成迁移、脱敏并提交)
DeletionStatus effective = effectiveStatus(userId);
if (effective == DeletionStatus.COMPLETED) {
// 独立事务(新快照)重读最新记录,避免读到本次事务内的旧 PENDING
AccountDeletion latest = readService.findLatestByUserId(userId);
return toResponse(latest, userId);
}
AccountDeletion rec = accountDeletionRepository.findByUserId(userId).orElse(null);
return toResponse(rec, userId);
}
/**
* 申请注销:NONE/PENDING → PENDING(幂等),COMPLETED → 409/9301。
*/
@Transactional
public AccountDeletionResponse initiate(Long userId) {
User user = userOrThrow(userId);
// 最后一个有效 ADMIN 保护
if (user.getRole() == User.Role.ADMIN
&& userRepository.countByRoleAndActiveTrue(User.Role.ADMIN) <= 1) {
throw new BusinessException(ErrorCode.LAST_ADMIN_CANNOT_DELETE);
}
// 懒转换:若已过期先完成,再按 COMPLETED 规则拒绝
if (effectiveStatus(userId) == DeletionStatus.COMPLETED) {
throw new BusinessException(ErrorCode.INVALID_DELETION_STATE);
}
entityManager.clear();
AccountDeletion rec = accountDeletionRepository.findByUserId(userId).orElse(null);
LocalDateTime now = LocalDateTime.now();
if (rec == null) {
rec = persist(AccountDeletion.builder()
.userId(userId)
.status(DeletionStatus.PENDING)
.requestedAt(now)
.effectiveAt(now.plusDays(coolingDays))
.version(0L)
.build());
auditDeletion("ACCOUNT_DELETION_INITIATED", rec.getId(), userId, "NONE->PENDING");
return toResponse(rec, userId);
}
switch (rec.getStatus()) {
case PENDING:
// 幂等:重复申请返回当前状态,不重复写审计
return toResponse(rec, userId);
case NONE:
rec.setStatus(DeletionStatus.PENDING);
rec.setRequestedAt(now);
rec.setEffectiveAt(now.plusDays(coolingDays));
rec.setCancelledAt(null);
persist(rec);
auditDeletion("ACCOUNT_DELETION_INITIATED", rec.getId(), userId, "NONE->PENDING");
return toResponse(rec, userId);
case COMPLETED:
default:
throw new BusinessException(ErrorCode.INVALID_DELETION_STATE);
}
}
/**
* 取消注销:仅 PENDING 且未到期可取消(PENDING → NONE);
* 非 PENDING 幂等返回当前状态;COMPLETED 不得恢复 → 409/9301。
*/
@Transactional
public AccountDeletionResponse cancel(Long userId) {
userOrThrow(userId);
if (effectiveStatus(userId) == DeletionStatus.COMPLETED) {
throw new BusinessException(ErrorCode.ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED);
}
entityManager.clear();
AccountDeletion rec = accountDeletionRepository.findByUserId(userId).orElse(null);
if (rec == null || rec.getStatus() == DeletionStatus.NONE) {
// 幂等:无注销或已取消,返回 NONE 当前状态
return toResponse(rec, userId);
}
LocalDateTime now = LocalDateTime.now();
if (rec.getStatus() == DeletionStatus.PENDING) {
if (rec.getEffectiveAt() != null && !rec.getEffectiveAt().isAfter(now)) {
throw new BusinessException(ErrorCode.ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED);
}
rec.setStatus(DeletionStatus.NONE);
rec.setCancelledAt(now);
persist(rec);
auditDeletion("ACCOUNT_DELETION_CANCELLED", rec.getId(), userId, "PENDING->NONE");
return toResponse(rec, userId);
}
throw new BusinessException(ErrorCode.INVALID_DELETION_STATE);
}
/**
* 返回用户当前"生效"注销状态;若处于 PENDING 且已到期,先经独立事务完成迁移再返回 COMPLETED。
* 供拦截器、登录校验、注销读写操作复用。
*/
public DeletionStatus effectiveStatus(Long userId) {
AccountDeletion rec = accountDeletionRepository.findByUserId(userId).orElse(null);
if (rec == null) {
return DeletionStatus.NONE;
}
if (rec.getStatus() == DeletionStatus.PENDING
&& rec.getEffectiveAt() != null
&& !rec.getEffectiveAt().isAfter(LocalDateTime.now())) {
completionWorker.completeIfExpired(userId);
return DeletionStatus.COMPLETED;
}
return rec.getStatus();
}
/**
* 定时任务:每天扫描所有已到期的 PENDING 记录并完成迁移(幂等、并发安全)。
* cron = 每天 03:00(Asia/Shanghai);实际以运行环境时区为准。
*/
@Transactional
@Scheduled(cron = "0 0 3 * * *", zone = "Asia/Shanghai")
public void finalizeExpiredDeletions() {
accountDeletionRepository
.findByStatusAndEffectiveAtLessThanEqual(DeletionStatus.PENDING, LocalDateTime.now())
.forEach(d -> completionWorker.completeIfExpired(d.getUserId()));
}
private void auditDeletion(String action, Long entityId, Long userId, String transition) {
auditService.log(userId, action, "ACCOUNT_DELETION", entityId,
action.toLowerCase() + "; userId=" + userId + "; transition=" + transition,
null, null, null);
}
/** 持久化并捕获 @Version 乐观锁冲突,映射为 9304;仅映射乐观锁,不把 DataIntegrityViolationException 一律转成 9304。 */
private AccountDeletion persist(AccountDeletion rec) {
try {
return accountDeletionRepository.saveAndFlush(rec);
} catch (ObjectOptimisticLockingFailureException e) {
log.warn("Account deletion concurrent update conflict for userId={}", rec.getUserId(), e);
throw new BusinessException(ErrorCode.ACCOUNT_DELETION_CONCURRENT_UPDATE);
}
}
private User userOrThrow(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
}
private AccountDeletionResponse toResponse(AccountDeletion rec, Long userId) {
if (rec == null) {
return AccountDeletionResponse.builder()
.userId(userId)
.status(DeletionStatus.NONE)
.version(0L)
.build();
}
return AccountDeletionResponse.builder()
.userId(rec.getUserId())
.status(rec.getStatus())
.requestedAt(rec.getRequestedAt())
.effectiveAt(rec.getEffectiveAt())
.cancelledAt(rec.getCancelledAt())
.completedAt(rec.getCompletedAt())
.version(rec.getVersion())
.build();
}
}

95
mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java

@ -0,0 +1,95 @@
package com.mydreamjob.deletion;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.ErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.Set;
/**
* 注销守卫拦截器。
*
* <p>基于当前已认证用户的账号注销状态拦截业务请求:
* <ul>
* <li>COMPLETED:除注销接口与 OPTIONS 外,所有普通业务接口一律返回 409/9303(旧 JWT 即使未过期也被拒绝)</li>
* <li>PENDING:拦截 POST/PUT/PATCH/DELETE 写操作返回 409/9302;GET 与 OPTIONS 放行</li>
* </ul>
* 注销接口(POST/GET /profile/me/deletion、POST /profile/me/deletion/cancel)始终放行,交由 Controller 决策。
* 未认证、未注销用户不受影响。
*/
@Component
@RequiredArgsConstructor
public class DeletionGuardInterceptor implements HandlerInterceptor {
private static final Set<String> DELETION_PATHS = Set.of(
"/profile/me/deletion",
"/profile/me/deletion/cancel");
private static final Set<String> WRITE_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
private final ObjectProvider<AccountDeletionService> deletionServiceProvider;
private final ObjectMapper objectMapper;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// 匿名用户(principal 为 "anonymousUser",非数字 userId)等同于未认证,直接放行
if (auth == null || !auth.isAuthenticated()
|| "anonymousUser".equals(String.valueOf(auth.getPrincipal()))
|| auth.getName() == null || !auth.getName().chars().allMatch(Character::isDigit)) {
return true;
}
// 切片测试(@WebMvcTest)等场景可能无此服务 Bean,此时不拦截
AccountDeletionService deletionService = deletionServiceProvider.getIfAvailable();
if (deletionService == null) {
return true;
}
Long userId = Long.valueOf(auth.getName());
String path = resolvePath(request);
if (DELETION_PATHS.contains(path)) {
return true;
}
DeletionStatus status = deletionService.effectiveStatus(userId);
if (status == DeletionStatus.COMPLETED) {
return reject(response, ErrorCode.ACCOUNT_DELETED, request); // 409/9303
}
if (status == DeletionStatus.PENDING && WRITE_METHODS.contains(request.getMethod().toUpperCase())) {
return reject(response, ErrorCode.ACCOUNT_DELETION_PENDING, request); // 409/9302
}
return true;
}
private boolean reject(HttpServletResponse response, ErrorCode errorCode,
HttpServletRequest request) throws Exception {
response.setStatus(409);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
String requestId = request.getHeader("X-Request-Id");
response.getWriter().write(
objectMapper.writeValueAsString(ApiResponse.error(errorCode.getCode(), errorCode.getMessage(), requestId)));
return false;
}
/** 解析去除 context-path 的请求路径,兼容真实服务器与 MockMvc。 */
private String resolvePath(HttpServletRequest request) {
String uri = request.getRequestURI();
String ctx = request.getContextPath();
if (ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)) {
return uri.substring(ctx.length());
}
return uri;
}
}

19
mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionStatus.java

@ -0,0 +1,19 @@
package com.mydreamjob.deletion;
/**
* 账号注销状态机状态。
*
* <pre>
* NONE ──申请注销--> PENDING
* PENDING ──冷静期内取消--> NONE
* PENDING ──30天到期--> COMPLETED(终态)
* </pre>
*/
public enum DeletionStatus {
/** 正常态:未注销 */
NONE,
/** 冷静期:可取消,业务写操作被限制 */
PENDING,
/** 终态:账号已注销并脱敏,登录被阻止 */
COMPLETED
}

48
mydreamjob-server/src/main/java/com/mydreamjob/deletion/dto/AccountDeletionResponse.java

@ -0,0 +1,48 @@
package com.mydreamjob.deletion.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.mydreamjob.deletion.DeletionStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 账号注销状态响应 DTO(POST/GET /profile/me/deletion、POST /profile/me/deletion/cancel)。
*
* <p>无记录时返回默认对象 {@code status=NONE、version=0},不自动创建数据库记录。
* null 字段不序列化,与项目现有 DTO 规范一致。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class AccountDeletionResponse {
@Schema(description = "用户 ID", example = "2")
private Long userId;
@Schema(description = "注销状态",
example = "NONE",
allowableValues = {"NONE", "PENDING", "COMPLETED"})
private DeletionStatus status;
@Schema(description = "申请注销时间(PENDING 时返回)", format = "date-time")
private LocalDateTime requestedAt;
@Schema(description = "生效时间 = requestedAt + 冷静期(PENDING 时返回)", format = "date-time")
private LocalDateTime effectiveAt;
@Schema(description = "取消注销时间(取消后返回)", format = "date-time")
private LocalDateTime cancelledAt;
@Schema(description = "完成注销时间(COMPLETED 时返回)", format = "date-time")
private LocalDateTime completedAt;
@Schema(description = "乐观锁版本号", example = "0")
private Long version;
}

19
mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java

@ -2,6 +2,7 @@ package com.mydreamjob.feedback;
import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.feedback.dto.CandidateFeedbackRequest; import com.mydreamjob.feedback.dto.CandidateFeedbackRequest;
import com.mydreamjob.feedback.dto.CandidateFeedbackResponse;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@ -23,34 +24,34 @@ public class CandidateFeedbackController {
@Operation(summary = "创建候选人反馈", description = "仅岗位所属企业;同一投递只能反馈一次") @Operation(summary = "创建候选人反馈", description = "仅岗位所属企业;同一投递只能反馈一次")
@PostMapping("/applications/{applicationId}/feedback") @PostMapping("/applications/{applicationId}/feedback")
public ResponseEntity<ApiResponse<Map<String, Object>>> create( public ResponseEntity<ApiResponse<CandidateFeedbackResponse>> create(
@PathVariable Long applicationId, @PathVariable Long applicationId,
@Valid @RequestBody CandidateFeedbackRequest req, @Valid @RequestBody CandidateFeedbackRequest req,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = resolveUserId(request); Long userId = resolveUserId(request);
Map<String, Object> view = feedbackService.createFeedback(userId, applicationId, req); CandidateFeedbackResponse response = feedbackService.createFeedback(userId, applicationId, req);
return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request)));
} }
@Operation(summary = "查询候选人反馈", description = "该投递的企业或学生可查看") @Operation(summary = "查询候选人反馈", description = "该投递的企业或学生可查看")
@GetMapping("/applications/{applicationId}/feedback") @GetMapping("/applications/{applicationId}/feedback")
public ResponseEntity<ApiResponse<Map<String, Object>>> get( public ResponseEntity<ApiResponse<CandidateFeedbackResponse>> get(
@PathVariable Long applicationId, @PathVariable Long applicationId,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = resolveUserId(request); Long userId = resolveUserId(request);
Map<String, Object> view = feedbackService.getFeedback(userId, applicationId); CandidateFeedbackResponse response = feedbackService.getFeedback(userId, applicationId);
return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request)));
} }
@Operation(summary = "修改候选人反馈", description = "仅岗位所属企业") @Operation(summary = "修改候选人反馈", description = "仅岗位所属企业")
@PutMapping("/applications/{applicationId}/feedback") @PutMapping("/applications/{applicationId}/feedback")
public ResponseEntity<ApiResponse<Map<String, Object>>> update( public ResponseEntity<ApiResponse<CandidateFeedbackResponse>> update(
@PathVariable Long applicationId, @PathVariable Long applicationId,
@Valid @RequestBody CandidateFeedbackRequest req, @Valid @RequestBody CandidateFeedbackRequest req,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = resolveUserId(request); Long userId = resolveUserId(request);
Map<String, Object> view = feedbackService.updateFeedback(userId, applicationId, req); CandidateFeedbackResponse response = feedbackService.updateFeedback(userId, applicationId, req);
return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request)));
} }
@Operation(summary = "删除候选人反馈", description = "仅岗位所属企业") @Operation(summary = "删除候选人反馈", description = "仅岗位所属企业")

65
mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java

@ -4,7 +4,10 @@ import com.mydreamjob.application.Application;
import com.mydreamjob.application.ApplicationRepository; import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.feedback.dto.CandidateFeedbackRequest; import com.mydreamjob.feedback.dto.CandidateFeedbackRequest;
import com.mydreamjob.feedback.dto.CandidateFeedbackResponse;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService; import com.mydreamjob.job.JobService;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
@ -25,6 +28,7 @@ public class CandidateFeedbackService {
private final CandidateFeedbackRepository feedbackRepository; private final CandidateFeedbackRepository feedbackRepository;
private final ApplicationRepository applicationRepository; private final ApplicationRepository applicationRepository;
private final JobService jobService; private final JobService jobService;
private final CompanyRepository companyRepository;
private static final ObjectMapper mapper = new ObjectMapper(); private static final ObjectMapper mapper = new ObjectMapper();
private Application findApplication(Long appId) { private Application findApplication(Long appId) {
@ -36,8 +40,14 @@ public class CandidateFeedbackService {
return jobService.getJob(app.getJobId()); return jobService.getJob(app.getJobId());
} }
private void assertCompanyOwner(Long companyUserId, Job job) { /**
if (!job.getCompanyId().equals(companyUserId)) * 企业归属校验:先按当前登录 userId 解析其拥有的企业,
* 再将企业 id 与 job.companyId 比较(userId 与 companyId 属不同命名空间,不可直接比较)。
*/
private void assertCompanyOwner(Long userId, Job job) {
Company company = companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN));
if (!company.getId().equals(job.getCompanyId()))
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
} }
@ -49,48 +59,50 @@ public class CandidateFeedbackService {
// ── 创建 ────────────────────────────────────────────────── // ── 创建 ──────────────────────────────────────────────────
@Transactional @Transactional
public Map<String, Object> createFeedback(Long companyUserId, Long applicationId, CandidateFeedbackRequest req) { public CandidateFeedbackResponse createFeedback(Long userId, Long applicationId, CandidateFeedbackRequest req) {
Application app = findApplication(applicationId); Application app = findApplication(applicationId);
Job job = findJob(app); Job job = findJob(app);
assertCompanyOwner(companyUserId, job); assertCompanyOwner(userId, job);
if (feedbackRepository.existsByApplicationId(applicationId)) if (feedbackRepository.existsByApplicationId(applicationId))
throw new BusinessException(ErrorCode.DUPLICATE_FEEDBACK); throw new BusinessException(ErrorCode.DUPLICATE_FEEDBACK);
CandidateFeedback fb = CandidateFeedback.builder() CandidateFeedback fb = CandidateFeedback.builder()
.applicationId(applicationId) .applicationId(applicationId)
.companyUserId(companyUserId) .companyUserId(userId)
.studentUserId(app.getStudentUserId()) .studentUserId(app.getStudentUserId())
.rating(req.getRating()).content(req.getContent()) .rating(req.getRating()).content(req.getContent())
.tags(toJson(req.getTags())) .tags(toJson(req.getTags()))
.build(); .build();
return toView(feedbackRepository.save(fb)); return toResponse(feedbackRepository.save(fb));
} }
// ── 查询 ────────────────────────────────────────────────── // ── 查询 ──────────────────────────────────────────────────
public Map<String, Object> getFeedback(Long userId, Long applicationId) { public CandidateFeedbackResponse getFeedback(Long userId, Long applicationId) {
Application app = findApplication(applicationId); Application app = findApplication(applicationId);
Job job = findJob(app); Job job = findJob(app);
// 企业或学生任一匹配即可查看 // 企业(按 userId 解析归属企业后比较企业 id)或学生任一匹配即可查看
boolean isCompany = job.getCompanyId().equals(userId);
boolean isStudent = app.getStudentUserId().equals(userId); boolean isStudent = app.getStudentUserId().equals(userId);
boolean isCompany = companyRepository.findByOwnerUserId(userId)
.map(c -> c.getId().equals(job.getCompanyId()))
.orElse(false);
if (!isCompany && !isStudent) if (!isCompany && !isStudent)
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId) CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND));
return toView(fb); return toResponse(fb);
} }
// ── 修改 ────────────────────────────────────────────────── // ── 修改 ──────────────────────────────────────────────────
@Transactional @Transactional
public Map<String, Object> updateFeedback(Long companyUserId, Long applicationId, CandidateFeedbackRequest req) { public CandidateFeedbackResponse updateFeedback(Long userId, Long applicationId, CandidateFeedbackRequest req) {
Application app = findApplication(applicationId); Application app = findApplication(applicationId);
Job job = findJob(app); Job job = findJob(app);
assertCompanyOwner(companyUserId, job); assertCompanyOwner(userId, job);
CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId) CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND));
@ -98,16 +110,16 @@ public class CandidateFeedbackService {
if (req.getRating() != null) fb.setRating(req.getRating()); if (req.getRating() != null) fb.setRating(req.getRating());
if (req.getContent() != null) fb.setContent(req.getContent()); if (req.getContent() != null) fb.setContent(req.getContent());
if (req.getTags() != null) fb.setTags(toJson(req.getTags())); if (req.getTags() != null) fb.setTags(toJson(req.getTags()));
return toView(feedbackRepository.save(fb)); return toResponse(feedbackRepository.save(fb));
} }
// ── 删除 ────────────────────────────────────────────────── // ── 删除 ──────────────────────────────────────────────────
@Transactional @Transactional
public void deleteFeedback(Long companyUserId, Long applicationId) { public void deleteFeedback(Long userId, Long applicationId) {
Application app = findApplication(applicationId); Application app = findApplication(applicationId);
Job job = findJob(app); Job job = findJob(app);
assertCompanyOwner(companyUserId, job); assertCompanyOwner(userId, job);
CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId) CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND));
@ -116,18 +128,17 @@ public class CandidateFeedbackService {
// ── 视图 ────────────────────────────────────────────────── // ── 视图 ──────────────────────────────────────────────────
public Map<String, Object> toView(CandidateFeedback fb) { public CandidateFeedbackResponse toResponse(CandidateFeedback fb) {
Map<String, Object> v = new HashMap<>(); if (fb == null) return null;
v.put("id", fb.getId()); return CandidateFeedbackResponse.builder()
v.put("applicationId", fb.getApplicationId()); .id(fb.getId())
v.put("companyUserId", fb.getCompanyUserId()); .applicationId(fb.getApplicationId())
v.put("studentUserId", fb.getStudentUserId()); .rating(fb.getRating())
v.put("rating", fb.getRating()); .content(fb.getContent())
v.put("content", fb.getContent()); .tags(fromJson(fb.getTags()))
v.put("tags", fromJson(fb.getTags())); .createdAt(fb.getCreatedAt())
v.put("createdAt", fb.getCreatedAt()); .updatedAt(fb.getUpdatedAt())
v.put("updatedAt", fb.getUpdatedAt()); .build();
return v;
} }
private String toJson(List<String> list) { private String toJson(List<String> list) {

45
mydreamjob-server/src/main/java/com/mydreamjob/feedback/dto/CandidateFeedbackResponse.java

@ -0,0 +1,45 @@
package com.mydreamjob.feedback.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* Candidate feedback response exposed to students and companies.
* Internal company/student user identifiers are intentionally excluded.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(name = "CandidateFeedbackResponse", description = "脱敏后的候选人反馈")
public class CandidateFeedbackResponse {
@Schema(description = "反馈 ID", example = "1")
private Long id;
@Schema(description = "投递 ID", example = "31")
private Long applicationId;
@Schema(description = "评分,范围 1-5", example = "5")
private Integer rating;
@Schema(description = "反馈内容", example = "沟通良好")
private String content;
@Schema(description = "反馈标签")
private List<String> tags;
@Schema(description = "创建时间", example = "2026-08-25T10:00:00")
private LocalDateTime createdAt;
@Schema(description = "更新时间", example = "2026-08-25T10:00:00")
private LocalDateTime updatedAt;
}

15
mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewCreateRequest.java

@ -1,6 +1,5 @@
package com.mydreamjob.interview.dto; package com.mydreamjob.interview.dto;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
@ -10,6 +9,8 @@ import java.time.LocalDateTime;
/** /**
* 创建面试请求 * 创建面试请求
* <p>ONLINE/OFFLINE 的条件字段校验(meetingUrl/address)由 InterviewService 业务校验负责,
* 以返回对应错误码 8406/8407;此处仅保留普通必填/格式校验。</p>
*/ */
@Data @Data
public class InterviewCreateRequest { public class InterviewCreateRequest {
@ -39,16 +40,4 @@ public class InterviewCreateRequest {
private String interviewerName; private String interviewerName;
private String notes; private String notes;
@AssertTrue(message = "meetingUrl is required for ONLINE interviews")
public boolean isMeetingUrlValid() {
if (!"ONLINE".equals(interviewType)) return true;
return meetingUrl != null && !meetingUrl.isBlank();
}
@AssertTrue(message = "address is required for OFFLINE interviews")
public boolean isAddressValid() {
if (!"OFFLINE".equals(interviewType)) return true;
return address != null && !address.isBlank();
}
} }

106
mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScore.java

@ -0,0 +1,106 @@
package com.mydreamjob.interviewscore;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 结构化面试评分实体(V31)。
*
* <p>一场面试一份评分(interview_id UNIQUE),评分人 = 岗位所属企业 owner。
* 状态机:DRAFT(草稿)→ SUBMITTED(终态)。
* overallScore 由服务端按 {@code (t+c+ps+rf+p)/5} 计算,客户端不可覆盖。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "interview_scores")
@EntityListeners(AuditingEntityListener.class)
public class InterviewScore {
public static final String STATUS_DRAFT = "DRAFT";
public static final String STATUS_SUBMITTED = "SUBMITTED";
public static final String RECOMMENDATION_STRONG_HIRE = "STRONG_HIRE";
public static final String RECOMMENDATION_HIRE = "HIRE";
public static final String RECOMMENDATION_NEUTRAL = "NEUTRAL";
public static final String RECOMMENDATION_NO_HIRE = "NO_HIRE";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "interview_id", nullable = false, unique = true)
private Long interviewId;
@Column(name = "application_id", nullable = false)
private Long applicationId;
@Column(name = "company_user_id", nullable = false)
private Long companyUserId;
@Column(name = "student_user_id", nullable = false)
private Long studentUserId;
@Column(name = "status", nullable = false, length = 20)
@Builder.Default
private String status = STATUS_DRAFT;
@Column(name = "technical_score")
private Integer technicalScore;
@Column(name = "communication_score")
private Integer communicationScore;
@Column(name = "problem_solving_score")
private Integer problemSolvingScore;
@Column(name = "role_fit_score")
private Integer roleFitScore;
@Column(name = "professionalism_score")
private Integer professionalismScore;
@Column(name = "overall_score", precision = 3, scale = 2)
private BigDecimal overallScore;
@Column(name = "recommendation", length = 20)
private String recommendation;
@Column(name = "comment", columnDefinition = "TEXT")
private String comment;
@Column(name = "submitted_at")
private LocalDateTime submittedAt;
@Version
@Column(name = "version", nullable = false)
private Long version;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/**
* 判断是否所有的五个维度都已填写。
*/
public boolean allDimensionsPresent() {
return technicalScore != null && communicationScore != null
&& problemSolvingScore != null && roleFitScore != null
&& professionalismScore != null;
}
}

89
mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreController.java

@ -0,0 +1,89 @@
package com.mydreamjob.interviewscore;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.interviewscore.dto.InterviewScoreHistoryItem;
import com.mydreamjob.interviewscore.dto.InterviewScoreResponse;
import com.mydreamjob.interviewscore.dto.InterviewScoreSaveRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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.util.List;
/**
* 结构化面试评分接口(V31)。
*
* <p>身份只从 JWT 获取,不接受 userId/studentUserId/companyId 参数。
* 仅岗位所属企业 owner 可评分;学生仅可查看已提交评分。
*/
@Tag(name = "结构化面试评分", description = "面试评分保存、查看、提交、历史")
@RestController
@RequiredArgsConstructor
public class InterviewScoreController {
private final InterviewScoreService interviewScoreService;
@Operation(summary = "保存评分草稿", description = "企业为已完成的面试保存评分草稿(支持重复保存)")
@PutMapping("/interviews/{interviewId}/score")
public ResponseEntity<ApiResponse<InterviewScoreResponse>> save(
@PathVariable Long interviewId,
@Valid @RequestBody InterviewScoreSaveRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
InterviewScoreResponse data = interviewScoreService.saveDraft(userId, interviewId, req,
AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "查看评分", description = "企业 owner 可查看草稿与已提交;学生只能在提交后查看")
@GetMapping("/interviews/{interviewId}/score")
public ResponseEntity<ApiResponse<InterviewScoreResponse>> get(
@PathVariable Long interviewId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(
interviewScoreService.getScore(userId, interviewId), getRequestId(request)));
}
@Operation(summary = "提交评分", description = "将草稿提交为终态,提交后不可修改")
@PostMapping("/interviews/{interviewId}/score/submit")
public ResponseEntity<ApiResponse<InterviewScoreResponse>> submit(
@PathVariable Long interviewId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(
interviewScoreService.submitScore(userId, interviewId, AuditContext.from(request)),
getRequestId(request)));
}
@Operation(summary = "评分历史", description = "查看评分快照历史;无历史返回空数组")
@GetMapping("/interviews/{interviewId}/score/history")
public ResponseEntity<ApiResponse<List<InterviewScoreHistoryItem>>> history(
@PathVariable Long interviewId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(
interviewScoreService.getHistory(userId, interviewId), getRequestId(request)));
}
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
return Long.valueOf(auth.getName());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save