213 changed files with 32460 additions and 131 deletions
@ -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。 |
||||
@ -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`;业务表零改动 | ✅ | |
||||
@ -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`。 |
||||
@ -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<String> | 是 | 标签数组,无标签时返回空数组 | |
||||
|
| `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. 未修改已验收接口语义。 |
||||
@ -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)`。不新增错误码。 |
||||
@ -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。 |
||||
@ -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 |
||||
@ -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`,天然隔离企业数据。 |
||||
|
- 候选人排名/趋势不写快照、不触发评分计算,仅读最新期已存在快照。 |
||||
@ -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(黑盒阶段除外)。 |
||||
@ -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`。 |
||||
@ -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**。 |
||||
File diff suppressed because one or more lines are too long
@ -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、绝对路径。 |
||||
@ -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` 等敏感值。 |
||||
@ -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 兼容。 |
||||
@ -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。 |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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]"); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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 <= 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); |
||||
|
} |
||||
@ -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; } |
||||
|
} |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
@ -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(当前月份),每候选人读取 <= 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(); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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; // 有分候选人数
|
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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 -> 均值
|
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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 维度
|
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
@ -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; } |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
|
||||
|
/** 本人百分位;有分候选人数 < 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; |
||||
|
} |
||||
|
} |
||||
@ -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 <token>") |
||||
|
public class OpenApiSecurityConfig { |
||||
|
|
||||
|
@Bean |
||||
|
public OpenAPI homepageOpenAPI() { |
||||
|
return new OpenAPI() |
||||
|
.info(new Info() |
||||
|
.title("职来 API") |
||||
|
.version("v1")); |
||||
|
} |
||||
|
} |
||||
@ -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("/**"); |
||||
|
} |
||||
} |
} |
||||
|
|||||
@ -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) { |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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) { |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
package com.mydreamjob.deletion; |
||||
|
|
||||
|
/** |
||||
|
* 账号注销状态机状态。 |
||||
|
* |
||||
|
* <pre> |
||||
|
* NONE ──申请注销--> PENDING |
||||
|
* PENDING ──冷静期内取消--> NONE |
||||
|
* PENDING ──30天到期--> COMPLETED(终态) |
||||
|
* </pre> |
||||
|
*/ |
||||
|
public enum DeletionStatus { |
||||
|
/** 正常态:未注销 */ |
||||
|
NONE, |
||||
|
/** 冷静期:可取消,业务写操作被限制 */ |
||||
|
PENDING, |
||||
|
/** 终态:账号已注销并脱敏,登录被阻止 */ |
||||
|
COMPLETED |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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…
Reference in new issue