diff --git a/docs/acceptance-candidate-scoring-governance.md b/docs/acceptance-candidate-scoring-governance.md new file mode 100644 index 0000000..f8c0921 --- /dev/null +++ b/docs/acceptance-candidate-scoring-governance.md @@ -0,0 +1,46 @@ +# 评分治理基础模块 验收记录 + +> 日期:2026-08-20 · 状态:✅ 已验收(Docker/MySQL 黑盒 PASS=48 · FAIL=0 · SKIP=0) + +## 1. Flyway V25 迁移结果 +- 最新成功迁移 **V25(candidate scoring governance,success=1)** ✅ +- H2(测试)与 MySQL(运行库)均已成功应用。 + +## 2. 四张治理表 +- `candidate_scoring_appeals`(`uk_csa_active` active_key 唯一、`uk_csa_idem` idempotency_key 唯一、dimension/status CHECK) +- `candidate_scoring_appeal_history`(含 actor_role) +- `candidate_scoring_snapshot_history`(回滚前后 score/version/rulesVersion) +- `candidate_scoring_rollback_limits`(`rollback_year`,UNIQUE(student_user_id, rollback_year)) + +## 3. 黑盒结果 +**PASS=48 · FAIL=0 · SKIP=0**(Docker/MySQL 真实环境) +- 学生:申诉→PENDING、列表/详情、history 脱敏、同幂等键同体重放 200、不同体 409/8607、同维度同周期 409/8602、撤销→CANCELLED、重复撤销 409/8606 +- 管理员:列表、REJECT 缺 reason 400/8604、APPROVE→APPROVED、重复 APPROVE 幂等、终态改决定 409/8603、回滚→ROLLED_BACK、重复回滚幂等、history 顺序 PENDING→APPROVED→ROLLED_BACK +- 回滚限流:3 次后第 4 次 409/8605,rollback_limits 计数保持 3 +- 快照审计:afterVersion = beforeVersion + 1,rulesVersion 正确 +- 权限:COMPANY 全 403/2002、STUDENT 访问 admin 403/2002、跨学生 403、未登录 401 +- 数据隔离:jobs/applications/offers/candidate_feedback/student_profiles/student_preferences 零改动 +- 回归:既有 6 个评分接口均 HTTP 200 + +## 4. OpenAPI 路径数 +**95 paths**(静态 docs/openapi-rc.json 与运行时 /v3/api-docs 一致;治理新增 8 paths / 9 operations,POST+GET 共享 /appeals) + +## 5. 权限矩阵 +| 接口 | 未登录 | STUDENT | COMPANY | ADMIN | +|---|---|---|---|---| +| 申诉 1–4 | 401 | ✅ 本人 | 403 | 403 | +| 历史(8) | 401 | ✅ 本人(脱敏) | 403 | ✅ 全量 | +| admin 5/6/7/9 | 401 | 403 | 403 | ✅ | + +## 6. 回滚前后快照审计 +`candidate_scoring_snapshot_history` 记录 action=ROLLBACK、before/after score、before/after version、before/after rules_version;`afterVersion = beforeVersion + 1`。 + +## 7. 年度回滚上限 +每学生每自然年 ≤3 次;`SELECT ... FOR UPDATE` 悲观锁串行化并发;第 4 次稳定 409/8605,计数不超额。 + +## 8. 已修复缺陷(验收中发现) +1. **学生详情泄露 `reviewerId`**:AppealResponse 对 reviewerId 误加 `@JsonInclude(ALWAYS)`(学生端 null 也被序列化)。修复:移除 ALWAYS,null 由全局 NON_NULL 省略。复验:学生响应不含 reviewerId/actorUserId/phone/email/passwordHash/resumeUrl ✅ +2. **快照 afterVersion 读到持久化上下文旧值**(before=after)。修复:afterVersion 确定性计算 `= beforeVersion + 1`(或 0)。复验:before=1、after=2 ✅ + +## 9. 备注 +未删除业务数据;未修改 V1–V24;未修改已验收接口。治理数据仅写入 4 张治理表 + candidate_dimension_scores 新版本 + audit_logs。 diff --git a/docs/acceptance-candidate-scoring.md b/docs/acceptance-candidate-scoring.md new file mode 100644 index 0000000..bd07bcb --- /dev/null +++ b/docs/acceptance-candidate-scoring.md @@ -0,0 +1,55 @@ +# Candidate Scoring Engine 验收记录(版本冻结) + +> 日期:2026-08-19 · 状态:✅ **冻结全绿**(旧评分卡 MySQL 缺陷已修复并复验,见 §7) + +## 1. 构建与测试 +| 项 | 结果 | +|---|---| +| `mvn clean verify` | **869 tests · Failures=0 · Errors=0 · Skipped=0 · BUILD SUCCESS** ✅ | + +## 2. Candidate Scoring 黑盒(重跑) +**PASS=25 · FAIL=0 · SKIP=0** ✅ +- STUDENT 综合评分卡(8 维、weight 12.50、rulesVersion=candidate-scoring-v0.1、PENDING/INSUFFICIENT score=null) +- 维度明细(EDUCATION 全字段);非法维度 → 400 +- trend months=6(按 period 独立归一化);months=0/25 → 400 +- 企业评分卡(app 31)→ 200,响应仅白名单字段 +- 越权:跨企业 403/8004、不存在 404/8002、ADMIN 403/2002、学生接口企业/管理员 403、未登录 401 + +## 3. 写入隔离 +- 评分仅写入 `candidate_dimension_scores`(同周期 upsert、version 递增 5→6) +- jobs/applications/student_profiles/offers/candidate_feedback 行数零改动 ✅ + +## 4. OpenAPI +- 静态 `docs/openapi-rc.json`:**87 paths** +- 运行时 `/api/v1/v3/api-docs`:**87 paths**,路径集合与静态完全一致 ✅ + +## 5. Flyway +- 最新成功迁移:**V24(candidate scoring)** ✅ +- `candidate_dimension_scores` 表存在,CHECK/唯一约束在位 ✅ + +## 6. 基础设施 +- MySQL / Redis / RabbitMQ healthy;queue.notification / dlq ready=0 unacked=0 ✅ +- RabbitMQ 应用连接:修复为使用系统环境变量 `RABBITMQ_VHOST` 被错误覆盖的问题(以 `$env:RABBITMQ_VHOST="/"` 启动后连接正常,无 vhost 报错)✅ + +## 7. 冻结发现(已修复并复验) +- 旧接口回归曾发现:`/profile/me/candidate-score-card` 在 **MySQL 上返回 HTTP 500**。 +- 根因:`CandidateScoreCardService.getScoreCard` 使用 `SELECT CAST(cf.tags AS VARCHAR) ...`,**MySQL 8 不支持 `CAST(... AS VARCHAR)`**(仅 `CHAR`);H2 支持故单测/构建通过,MySQL 运行时报 `SQLSyntaxErrorException`。 +- **已修复**(负责人批准,一行):`CandidateScoreCardService.java` 第 60 行 `CAST(cf.tags AS VARCHAR)` → `CAST(cf.tags AS CHAR)`。 +- **复验结果**:`candidate-score-card` / `-trend` / `-percentile` 三个接口在真实 MySQL 环境均返回 **HTTP 200**;`mvn clean verify` 869 全绿。 +- **冻结结论**:✅ **版本冻结宣布为全绿**(详见 §1–§6 与 §8)。 + +## 8. 备注 +- 未修改任何已验收接口语义;未新增 Flyway;未删除业务数据。 +- 后端运行方式:`mvn spring-boot:run`(最新构建,环境变量由 backend-infra/.env 注入;Windows 需显式 `$env:RABBITMQ_VHOST="/"`)。 + +## 9. Release Candidate 检查清单(冻结全绿) +| 检查项 | 值 | 状态 | +|---|---|---| +| `mvn clean verify` | **869 tests · 0 failures · 0 errors · 0 skipped · BUILD SUCCESS** | ✅ | +| Candidate Scoring 黑盒 | **PASS=25 · FAIL=0 · SKIP=0** | ✅ | +| OpenAPI | **87 paths**(静态 `docs/openapi-rc.json` 与运行时 `/v3/api-docs` 集合一致) | ✅ | +| Flyway | 最新 **V24**(candidate scoring) | ✅ | +| Docker | MySQL / Redis / RabbitMQ 均 healthy | ✅ | +| RabbitMQ 队列 | queue.notification、queue.notification.dlq 均 ready=0 unacked=0 | ✅ | +| 旧评分卡回归 | score-card / score-trend / percentile 均 HTTP 200 | ✅ | +| 写入隔离 | 评分仅写 `candidate_dimension_scores`;业务表零改动 | ✅ | diff --git a/docs/account-deletion-api.md b/docs/account-deletion-api.md new file mode 100644 index 0000000..77d9a3b --- /dev/null +++ b/docs/account-deletion-api.md @@ -0,0 +1,240 @@ +# 账号注销(Account Deletion)API 联调说明 + +> 模块:账号注销。当前登录用户可对自己的账号发起注销申请、查询状态、在冷静期内取消注销。 +> 适用于 **STUDENT / COMPANY / ADMIN** 所有登录角色;未登录一律返回 `HTTP 401`。 + +## 1. 接口概览 + +| 方法 | 路径 | 角色 | 说明 | +|------|------|------|------| +| POST | `/profile/me/deletion` | 所有登录角色 | 申请注销当前账号(`NONE → PENDING`;PENDING 重复提交幂等返回 200;COMPLETED 再申请返回 409/9301) | +| GET | `/profile/me/deletion` | 所有登录角色 | 查询当前用户注销状态;无记录返回默认对象 `NONE`,不写库 | +| POST | `/profile/me/deletion/cancel` | 所有登录角色 | 仅在 PENDING 且未超过 `effectiveAt` 时可取消(`PENDING → NONE`) | + +身份一律从 JWT / SecurityContext 获取,**不接受** `userId`、`candidateId` 等身份参数。 + +## 2. 注销状态机 + +状态仅有三种: + +```text +NONE —— 正常态(未注销) +PENDING —— 冷静期(可取消;业务写操作被限制) +COMPLETED —— 终态(账号已脱敏,登录被阻止,不可恢复) +``` + +转换规则: + +```text +NONE ----发起注销----> PENDING +PENDING ----冷静期内取消-> NONE +PENDING ----30天到期------> COMPLETED +COMPLETED(终态,不可恢复) +``` + +## 3. 冷静期 + +- 默认 **30 天**,可通过 `app.deletion.cooling-days`(环境变量 `DELETION_COOLING_DAYS`)配置。 +- 发起注销时 `effectiveAt = requestedAt + 冷静期`。 +- 冷静期内允许登录、允许 GET 查询,但**业务写操作被拦截**。 +- 超过 `effectiveAt` 后,PENDING 会转换为 COMPLETED;此后不可取消。 + +## 4. 默认 NONE 行为 + +`GET /profile/me/deletion` 在用户**从未发起注销**时返回默认对象,且**不会自动向数据库插入记录**: + +```json +{ + "code": 200, + "message": "ok", + "data": { + "userId": 2, + "status": "NONE", + "version": 0 + }, + "timestamp": "2026-08-25T10:00:00Z" +} +``` + +`requestedAt / effectiveAt / cancelledAt / completedAt` 仅在对应状态下返回(无值不输出)。 + +## 5. 请求与响应示例 + +### 5.1 发起注销 + +```http +POST /api/v1/profile/me/deletion +Authorization: Bearer +``` + +```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 +``` + +响应结构同默认对象;已在 PENDING 时返回上述 PENDING 结构。 + +### 5.3 取消注销(冷静期内) + +```http +POST /api/v1/profile/me/deletion/cancel +Authorization: Bearer +``` + +```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_` 作为 `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`。 \ No newline at end of file diff --git a/docs/candidate-feedback-hardening-design.md b/docs/candidate-feedback-hardening-design.md new file mode 100644 index 0000000..e3884b7 --- /dev/null +++ b/docs/candidate-feedback-hardening-design.md @@ -0,0 +1,161 @@ +# CandidateFeedback 响应脱敏与契约修复设计 + +> 状态:已批准实施 +> +> 目标:修复候选人反馈响应中的内部身份字段泄露,不改变既有接口语义。 + +## 1. 背景 + +`candidate_feedback` 模块已经提供企业创建/修改/删除反馈、企业和学生查看反馈的能力。当前服务层使用 `Map` 拼装响应,其中包含 `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` 作为接口响应; +4. Controller 使用 `ApiResponse`; +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. 未修改已验收接口语义。 diff --git a/docs/candidate-scoring-api.md b/docs/candidate-scoring-api.md new file mode 100644 index 0000000..b019ead --- /dev/null +++ b/docs/candidate-scoring-api.md @@ -0,0 +1,78 @@ +# 求职者评分体系 API + +> 求职者评分体系(Candidate Scoring Engine)v0.1 · 规则版本 `candidate-scoring-v0.1` +> 基础路径:`/api/v1` · 全部接口需 `Authorization: Bearer ` + +## 概述 + +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)`。不新增错误码。 diff --git a/docs/candidate-scoring-governance-api.md b/docs/candidate-scoring-governance-api.md new file mode 100644 index 0000000..d385bdc --- /dev/null +++ b/docs/candidate-scoring-governance-api.md @@ -0,0 +1,64 @@ +# 评分治理 API(Candidate Scoring Governance) + +> 规则版本 `candidate-scoring-v0.1` · 基础路径 `/api/v1` +> 为求职者评分提供申诉、审核、回滚、审计基础能力;**不改已验收评分接口与引擎规则**。 + +## 回滚语义 +回滚 = ① 记录回滚前快照(`candidate_scoring_snapshot_history`);② 按**当前数据**确定性重算(复用 `CandidateScoringCalculator`);③ 写入 `candidate_dimension_scores` 新版本;④ 记录回滚后快照;⑤ 申诉状态置 `ROLLED_BACK`;⑥ 写申诉历史与审计。**不恢复旧分数**——是"批准申诉后的确定性重新计算"。 + +## 接口(9 个接口落在 8 个路径,OpenAPI 87 → 95) + +| # | 接口 | 方法 | 角色 | +|---|---|---|---| +| 1 | `/profile/me/candidate-scoring/appeals` | POST | STUDENT 本人(提交申诉) | +| 2 | `/profile/me/candidate-scoring/appeals` | GET | STUDENT 本人(我的列表) | +| 3 | `/profile/me/candidate-scoring/appeals/{appealId}` | GET | 本人 / ADMIN | +| 4 | `/profile/me/candidate-scoring/appeals/{appealId}/cancel` | POST | STUDENT 本人(撤销,仅 PENDING) | +| 5 | `/admin/candidate-scoring/appeals` | GET | ADMIN | +| 6 | `/admin/candidate-scoring/appeals/{appealId}/decision` | POST | ADMIN | +| 7 | `/admin/candidate-scoring/appeals/{appealId}/rollback` | POST | ADMIN | +| 8 | `/admin/candidate-scoring/appeals/{appealId}/history` | GET | 本人(脱敏)/ ADMIN(全量) | +| 9 | `/admin/candidate-scoring/snapshots/{candidateUserId}/{dimension}/{period}/history` | GET | ADMIN | + +## 请求/响应 +- `CreateAppealRequest`:`dimension`(8 枚举)、`period`(yyyy-MM)、`reason`(必填,缺 → 8604)、`idempotencyKey`(可选)。 +- `AppealDecisionRequest`:`decision`(APPROVE/REJECT)、`reason`(REJECT 必填 → 8604)。 +- `AppealResponse`:appealId、candidateId、dimension、period、status、reason、decision、decisionReason、reviewerId(学生端不返回)、reviewedAt、rollbackAt、scoreAtAppeal、snapshotVersionAtAppeal、rulesVersion、createdAt、updatedAt。 +- 历史项:`actorRole`(STUDENT/ADMIN,学生端可见);`actorUserId`(**仅 ADMIN**)。 +- 快照审计项:回滚前后 `score/version/rulesVersion` 完整变化(仅 ADMIN)。 + +## 权限矩阵 +| 接口 | 未登录 | STUDENT | COMPANY | ADMIN | +|---|---|---|---|---| +| 申诉 1–4 | 401 | ✅ 本人 | 403 | 403 | +| 历史(8) | 401 | ✅ 本人(脱敏) | 403 | ✅ 全量 | +| admin 5/6/7/9 | 401 | 403 | 403 | ✅ | + +身份一律取自 JWT;学生仅能访问自己的申诉;COMPANY 全 403/2002;ADMIN 的 `candidateUserId` 路径参数仅 ADMIN 可用。 + +## 状态机 +``` +PENDING ──> APPROVED ──> ROLLED_BACK(终态) + │ └────────────> REJECTED(终态) + └────────────> CANCELLED(终态) +``` + +## 并发与幂等 +- `active_key = {candidate}:{dimension}:{period}` 唯一约束保护 PENDING/APPROVED;终态清空 active_key 允许重新提交。 +- 同 idempotencyKey 同请求体 → 200 幂等;不同请求体 → **409/8607**;并发创建唯一冲突 → **409/8602**。 +- 审核 `@Version` 乐观锁,并发冲突 → 409/8606。 +- 回滚限流 `candidate_scoring_rollback_limits` + `SELECT ... FOR UPDATE` 悲观锁;每学生每自然年 ≤3 次,第 4 次 **409/8605**;重复回滚幂等 200。 + +## 错误码(新增 86xx) +| code | HTTP | 含义 | +|---|---|---| +| 8601 | 404 | 申诉不存在 | +| 8602 | 409 | 同维度+周期已有活跃申诉 | +| 8603 | 409 | 申诉已决定 | +| 8604 | 400 | 缺少理由 | +| 8605 | 409 | 年度回滚次数超限(3) | +| 8606 | 409 | 非法状态流转 / 并发冲突 | +| 8607 | 409 | 幂等键冲突(请求体不同) | + +## 脱敏 +学生端响应不返回:reviewerId、actorUserId、phone、email、passwordHash、resumeUrl、内部评分日志。ADMIN 可见 reviewerId 与 actorUserId。 diff --git a/docs/company-candidate-api.md b/docs/company-candidate-api.md new file mode 100644 index 0000000..939320c --- /dev/null +++ b/docs/company-candidate-api.md @@ -0,0 +1,125 @@ +# 企业候选人看板 API + +## 1. 企业候选人列表 + +``` +GET /api/v1/company/candidates?[jobId][&status][&keyword][&sort][&order][&page][&size] +``` + +### 请求参数 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| jobId | Long | 否 | 全部 | 按岗位筛选 | +| status | String | 否 | 全部 | SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN | +| keyword | String | 否 | — | 学生姓名/学校/学历关键词搜索 | +| sort | String | 否 | createdAt | createdAt/updatedAt | +| order | String | 否 | desc | asc/desc | +| page | int | 否 | 0 | 页码 (0-based) | +| size | int | 否 | 20 | 每页条数 (1-100) | + +### 权限 + +`COMPANY` 角色,从 JWT 获取用户 → 查 Company → 用 companyId 过滤数据。 + +### 响应 + +```json +{ + "code": 200, + "message": "ok", + "data": { + "items": [{ + "applicationId": 1, + "jobId": 10, + "jobTitle": "Java 后端工程师", + "status": "SUBMITTED", + "candidateName": "张三", + "candidateSchool": "清华大学", + "candidateEducation": "本科", + "candidateGraduationYear": 2026, + "candidateAvatarUrl": "https://...", + "createdAt": "2026-08-15T10:00:00", + "updatedAt": "2026-08-16T14:00:00" + }], + "total": 156, + "page": 0, + "size": 20, + "totalPages": 8 + }, + "requestId": "abc-123", + "timestamp": "2026-08-17T15:00:00Z" +} +``` + +### 错误码 + +| 状态码 | 错误码 | 说明 | +|--------|--------|------| +| 400 | 1001 | 参数非法 (page<0, size<1或>100, 无效status/sort/order, keyword>100, jobId<=0) | +| 401 | — | 未认证 | +| 403 | 2002 | 非 COMPANY 角色 | +| 404 | 5001 | 企业不存在 | + +--- + +## 2. 企业候选人详情 + +``` +GET /api/v1/company/candidates/{applicationId} +``` + +### 权限 + +`COMPANY` 角色,且该投递对应的岗位必须属于当前企业。 + +### 响应 + +```json +{ + "code": 200, + "message": "ok", + "data": { + "applicationId": 1, + "jobId": 10, + "jobTitle": "Java 后端工程师", + "jobCity": "北京", + "jobEmploymentType": "全职", + "status": "SUBMITTED", + "coverLetter": "尊敬的HR...", + "createdAt": "2026-08-15T10:00:00", + "updatedAt": "2026-08-16T14:00:00", + "candidateName": "张三", + "candidateAvatarUrl": "https://...", + "candidateBio": "热爱编程...", + "candidateLocation": "北京", + "candidateSchool": "清华大学", + "candidateEducation": "本科", + "candidateGraduationYear": 2026, + "candidateSkills": ["Java", "Spring Boot", "MySQL"] + }, + "requestId": "abc-123", + "timestamp": "2026-08-17T15:00:00Z" +} +``` + +### 错误码 + +| 状态码 | 错误码 | 说明 | +|--------|--------|------| +| 400 | 1001 | 参数非法 | +| 401 | — | 未认证 | +| 403 | 2002 | 非 COMPANY 角色 | +| 403 | 8004 | 投递不属于本企业 | +| 404 | 5001 | 企业不存在 | +| 404 | 8002 | 投递不存在 | + +--- + +## 安全说明 + +- companyId 从 JWT 推导,不接受请求参数 +- 所有数据限定在 `jobs.company_id = :companyId`,天然隔离 +- 不返回 `studentUserId`、`phone`、`passwordHash`、`email`、原始 `resumeUrl` +- 列表不返回 `coverLetter`,详情返回 `coverLetter` +- 详情使用单次 JOIN 查询 (applications + jobs + student_profiles + users),不产生 N+1 \ No newline at end of file diff --git a/docs/company-scoring-dashboard-api.md b/docs/company-scoring-dashboard-api.md new file mode 100644 index 0000000..b81540e --- /dev/null +++ b/docs/company-scoring-dashboard-api.md @@ -0,0 +1,276 @@ +# 企业评分运营看板 API + +> 企业评分运营看板 / 评分排名 / 评分趋势 v0.1 +> 基础路径:`/api/v1` · 全部接口需 `Authorization: Bearer ` + +## 概述 + +- **只读聚合**:仅查询 `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`,天然隔离企业数据。 +- 候选人排名/趋势不写快照、不触发评分计算,仅读最新期已存在快照。 diff --git a/docs/competition-ranking-api.md b/docs/competition-ranking-api.md new file mode 100644 index 0000000..fef4dfc --- /dev/null +++ b/docs/competition-ranking-api.md @@ -0,0 +1,199 @@ +# 同岗位竞争力排名 API(V32) + +> 版本:V32 设计锁定版 +> 状态:已编码实现(含状态口径修复) +> 适用范围:学生端只读接口,不修改既有接口、百分位、评分引擎与企业看板。 + +> ⚠️ 状态口径(最终): +> **投递已 WITHDRAWN 或 REJECTED 的学生已退出候选人池,访问排名接口返回 403/2002。** +> “进入排名接口”的有效投递状态与 cohort 完全一致:仅 `SUBMITTED / VIEWED / INTERVIEW / HIRED` 允许进入。 + +## 1. 定位与边界 + +为已投递某岗位的 STUDENT 提供该岗位候选人池内的竞争力排名。与既有接口边界: + +| 接口 | 是否改动 | 关系 | +|---|---|---| +| `GET /profile/me/candidate-percentile` | 不改 | 全局 cohort、基于 candidate_feedback 均分;本接口为单岗位、基于候选评分快照 | +| `GET /profile/me/candidate-scoring` | 不改 | 复用其真实公式 `overallScoreFromSnapshots` | +| `GET /company/.../scoring/dashboard/candidate-rankings` | 不改 | 企业端全公司视角(含姓名/学校);本接口学生端、匿名 | +| `GET /jobs/{jobId}/match` | 不改 | 偏好确定性匹配诊断;本接口为候选人间竞争排名 | + +## 2. 接口 + +``` +GET /api/v1/jobs/{jobId}/competition-ranking +``` + +- 角色:仅 STUDENT。 +- 身份从 JWT 获取,不接收 `userId`/`studentUserId` 参数。 +- 新增 1 条 GET 路径,OpenAPI 116 → 117。 + +## 3. 权限矩阵 + +| 身份/场景 | HTTP | code | 说明 | +|---|---|---|---| +| 未登录 | 401 | 2001 | SecurityConfig `/jobs/**` 为 authenticated | +| COMPANY / ADMIN | 403 | 2002 | Service 层 `assertStudentRole` | +| STUDENT 已投递该岗位且状态有效(SUBMITTED/VIEWED/INTERVIEW/HIRED) | 200 | — | 正常返回 | +| STUDENT 未投递该岗位 | 403 | 2002 | 岗位存在但不属于其候选人池,不泄露候选人数据 | +| STUDENT 唯一投递为 WITHDRAWN 或 REJECTED | 403 | 2002 | 已退出候选人池;与 cohort 排除口径一致,不返回 200 | +| jobId 为空或 <=0 | 400 | 1001 | BAD_REQUEST | +| 岗位不存在 | 404 | 7001 | JOB_NOT_FOUND | +| 岗位非 PUBLISHED(DRAFT/CLOSED 等) | 404 | 7001 | 不暴露岗位是否存在候选人池 | + +不新增错误码,复用既有 ErrorCode。 + +## 4. 响应结构 + +``` +ApiResponse + +CompetitionRankingResponse { + 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 dimensions; // 本人各维度 {dimension,score,status,weight} + List gaps; // 缺口建议(基于维度状态,简单实现) + List 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(黑盒阶段除外)。 \ No newline at end of file diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index eff16d1..3406e9e 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -25,6 +25,8 @@ $env:DB_USERNAME = "mydreamjob" $env:DB_PASSWORD = "<你的数据库密码>" $env:RABBITMQ_USERNAME = "mydreamjob_dev" $env:RABBITMQ_PASSWORD = "<你的 RabbitMQ 密码>" +# 注意:本机 Windows 环境变量 RABBITMQ_VHOST 可能被设置为无关路径,必须显式覆盖为默认 vhost +$env:RABBITMQ_VHOST = "/" $env:JWT_SECRET = "<至少256位的随机密钥>" $env:SMS_MOCK_ENABLED = "true" mvn spring-boot:run -Dspring-boot.run.profiles=dev @@ -120,11 +122,11 @@ Authorization: Bearer | HTTP | 业务 code | 含义 | |------|-----------|------| -| 401 | 2001 | 未登录 / token 失效 | -| 403 | 2002 | 无角色权限 / 越权 | -| 400 | 1001, 3002-3006, 8004... | 参数错误 | -| 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 9105 | 资源不存在 | -| 409 | 5003, 7003, 8001, 8003, 9101, 9103 | 重复 / 非法状态流转 | +| 401 | 401(安全过滤器) | 未登录 / 无 token | +| 403 | 2002, 8004, 8303, 8402 | 无角色权限 / 越权(含企业归属不匹配) | +| 400 | 1001, 3002-3006, 8404-8408, 8505, 9105 | 参数错误 / 非法请求体 / 资源未命中默认映射 | +| 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 8305, 8501 | 资源不存在 | +| 409 | 5003, 7003, 8001, 8003, 8301-8302, 8304, 8502-8504, 9101, 9103 | 重复 / 非法状态流转 | | 500 | 1002, 9001 | 服务器内部错误 | **前端策略**:优先判断 HTTP 状态码,再读取 `body.code` 显示业务信息。 @@ -181,3 +183,475 @@ Authorization: Bearer ### 8.3 岗位对比(公开) `GET /jobs/compare?ids=1,2,3`(无需登录,2-3 个公开岗位),返回公开字段与公开评分。 + +## 9. Candidate Scoring(求职者评分体系)联调 + +规则版本固定 `rulesVersion = "candidate-scoring-v0.1"`;八维 `EDUCATION / INTERNSHIP / ABILITY / COMPLIANCE / STABILITY / GROWTH / INTEGRITY / ACTIVITY`,默认权重各 12.50,参与计算维度重新归一化。 + +### 9.1 接口 + +| 接口 | 方法 | 角色 | 说明 | +|------|------|------|------| +| `/profile/me/candidate-scoring` | GET | STUDENT 本人 | 综合评分卡(8 维 + overallScore + weights + status) | +| `/profile/me/candidate-scoring/dimensions/{dimension}` | GET | STUDENT 本人 | 单维度明细(subScores/reasons/dataSources);非法 dimension → 400 | +| `/profile/me/candidate-scoring/trend?months=6` | GET | STUDENT 本人 | 月度趋势(months 1–24,越界 400) | +| `/company/candidates/{applicationId}/scoring` | GET | COMPANY(岗位归属) | 企业只读评分卡 | + +### 9.2 权限矩阵 + +| 接口 | 未登录 | STUDENT | COMPANY | ADMIN | +|------|--------|---------|---------|-------| +| 学生 3 接口 | 401 | ✅ 本人 | 403 | 403 | +| 企业评分 | 401 | 403 | ✅(岗位归属) | 403 | + +- 企业归属:`companyRepository.findByOwnerUserId(jwtUserId)` 无企业→403/2002;`company.id != job.companyId`→403/8004;application 不存在→404/8002。 + +### 9.3 企业响应脱敏字段(禁止展示) + +企业评分响应**仅含** `applicationId / overallScore / status / dimensions[]{dimension,score,status} / generatedAt / rulesVersion`;**不得出现** `studentUserId / candidateId / phone / email / resumeUrl / subScores / 内部日志`。 + +### 9.4 状态与错误码 + +- 维度状态:`OK / ESTIMATED / INSUFFICIENT_DATA / PENDING`;PENDING/INSUFFICIENT_DATA 的 `score` 为 `null`。 +- 综合卡状态:`OK / PARTIAL / INSUFFICIENT_DATA / PENDING`;无可计算维度时 `overallScore=null`。 +- 错误码复用:`BAD_REQUEST(1001)`、`UNAUTHORIZED(2001)`(未登录实际 401)、`FORBIDDEN(2002)`、`APPLICATION_NOT_FOUND(8002)`、`APPLICATION_OWNER_MISMATCH(8004)`。 + +### 9.5 前端注意 + +- 学生端先调综合卡,按 `status` 决定是否渲染 `overallScore`;PENDING/INSUFFICIENT 维度显示"数据不足/待补充"。 +- 企业端评分卡仅用于候选人详情展示,勿透传任何候选人联系方式或简历地址。 + +## 10. 企业评分运营看板联调 + +全部只读聚合;候选人按学生去重,综合分复用评分引擎公式;岗位评价口径为「审核通过 + PUBLISHED」。详见 [docs/company-scoring-dashboard-api.md](company-scoring-dashboard-api.md)。 + +### 10.1 接口 + +| 接口 | 方法 | 角色 | 说明 | +|------|------|------|------| +| `/company/scoring/dashboard/overview` | GET | COMPANY | 企业评分运营概览(岗位/评价/候选人统计) | +| `/company/scoring/dashboard/job-rankings?page&size` | GET | COMPANY | 岗位评分排名(avgRating DESC, jobId DESC) | +| `/company/scoring/dashboard/job-rankings/{jobId}` | GET | COMPANY | 单岗位评分排名明细(跨企业 400/7002) | +| `/company/scoring/dashboard/candidate-rankings?page&size` | GET | COMPANY | 候选人评分排名(按学生去重,overallScore DESC) | +| `/company/scoring/dashboard/candidate-rankings/{applicationId}` | GET | COMPANY | 单候选人评分排名明细(跨企业 403/8004) | +| `/company/scoring/dashboard/trend?months=6` | GET | COMPANY | 企业评分趋势(岗位评价 + 候选人综合分,按月) | +| `/admin/scoring/dashboard/overview` | GET | ADMIN | 全局运营概览(全平台统计 + 企业 Top10) | + +### 10.2 权限矩阵 + +| 接口 | 未登录 | STUDENT | COMPANY | ADMIN | +|------|--------|---------|---------|-------| +| 企业看板 6 接口 | 401 | 403 | ✅(本企业) | 403 | +| 全局概览 | 401 | 403 | 403 | ✅ | + +### 10.3 关键口径 + +- 候选人去重:同一学生多次投递视为 1 名候选人,代表投递取企业下 `applicationId` 最大一条;`WITHDRAWN`/`REJECTED` 排除。 +- 综合分:复用 `CandidateScoringCalculator`,读取候选人 `<= 当前月` 最新期快照计算;无可计算维度 → `overallScore=null`。 +- 岗位评价:`status=PUBLISHED` 且 `moderation_status ∈ {AUTO_APPROVED, APPROVED}`(与评价汇总口径一致)。 +- `percentile`:在有分候选人中计算(升序),无分 → `null`;`sampleCount` 为有分候选人数。 +- 分页参数:`page`(0 起)、`size`(1–100),`months`(1–24,默认 6)。 + +### 10.4 前端注意 + +- 看板为只读统计,勿依赖实时刷新频次;趋势冷启动期通常只有当前月数据,随评分调用/评价累积逐渐补齐。 +- 候选人排名项不返回联系方式与简历地址,仅返回姓名/学校/综合分/百分位/维度分(维度分仅在详情接口)。 +- `avgJobScore`/`avgCandidateScore`/`overallScore` 无数据时返回 `null`,前端按「暂无数据」处理。 + +## 11. Offer 进阶(还价 / 对比 / 统计)联调 + +在基础 Offer 之上新增薪资洽谈、Offer 对比、企业统计,并扩展 `WITHDRAWN` 撤销状态。 +完整字段/错误码见 [docs/offer-advance-api.md](offer-advance-api.md)。OpenAPI 总路径数 102 → **107**。 + +### 11.1 接口 + +| 接口 | 方法 | 角色 | 说明 | +|------|------|------|------| +| `/offers/{id}/counter-offers` | POST | STUDENT 本人 | 发起还价(SENT 时自动进入 NEGOTIATING) | +| `/offers/{id}/counter-offers` | GET | STUDENT 本人 / COMPANY(本企业) | 还价列表 | +| `/offers/{id}/counter-offers/{coId}/accept` | PATCH | COMPANY(本企业) | 接受还价 → Offer 成交(ACCEPTED) | +| `/offers/{id}/counter-offers/{coId}/reject` | PATCH | COMPANY(本企业) | 拒绝还价 → Offer 保持 NEGOTIATING | +| `/offers/compare?ids=1,2,3` | GET | STUDENT 本人 | 对比本人 2-5 个非 DRAFT Offer | +| `/offers/statistics?months=6` | GET | COMPANY | 企业 Offer 统计(状态分布/接受率/响应时长) | + +### 11.2 还价要点 + +- 仅 `SENT/NEGOTIATING` 可发起;候选人发起后 Offer 自动进入 `NEGOTIATING`,前端应刷新 Offer 状态。 +- 同一 Offer 同时最多一个 PENDING 还价;二次发起返回 409 `OFFER_COUNTER_OFFER_EXISTS`。 +- 企业 `accept` 后会更新 Offer 薪资/入职日期并置为 `ACCEPTED`;`reject` 后 Offer 仍为 `NEGOTIATING`,候选人可再次还价。 + +### 11.3 对比要点 + +- `ids` 为逗号分隔且 2-5 个、全为正数;**非法参数一律 400**,前端需先本地校验避免请求失败。 +- 返回项附带 `companyName`;被比较 Offer 含 `DRAFT` 视为 403 `OFFER_COMPARE_NOT_OWNER`,故只选已发送/洽谈中 Offer。 + +### 11.4 统计要点 + +- 仅企业可访问;`months` 1-24,越界按默认 6 处理。 +- `acceptanceRate` / `avgResponseHours` 无数据时为 `null`,前端按「暂无数据」处理;`byStatus` 恒含全部 7 个状态键。 + +### 11.5 撤销(WITHDRAWN) + +- 企业 `PATCH /offers/{id}/status` `{"status":"WITHDRAWN"}`,仅 `SENT/NEGOTIATING` 有效;其他状态 409。 +- 撤销/过期会使该 Offer 的 PENDING 还价变为 `EXPIRED`,Offer 自身释放"唯一有效 Offer"槽位;企业端应刷新 Offer + 还价列表。 + +### 11.6 新增错误码(已并入统一映射) + +| 错误码 | HTTP | 含义 | +|---|---|---| +| 8109 | 409 | 已存在 PENDING 还价 | +| 8110 | 404 | 还价不存在/不属于该 Offer | +| 8111 | 409 | 还价非 PENDING | +| 8113 | 409 | Offer 已撤销/过期,还价失效 | +| 8114 | 400 | Offer 状态不允许还价 | +| 8115 | 400 | 对比数量需 2-5 个 | +| 8116 | 403 | 对比含非本人/非有效 Offer | + +## 12. 学生个人资料 GET /profile/me 联调 + +### 12.1 基础信息 + +| 项 | 值 | +|----|-----| +| Base URL | `http://127.0.0.1:8080/api/v1` | +| 状态 | 已实现、已验收(运行时黑盒 18/18 PASS) | +| 接口 | `GET /api/v1/profile/me` | +| 认证 | 必须携带 `Authorization: Bearer ` | +| Token 获取 | 通过登录接口(`POST /auth/login`,开发环境为 Mock 验证码)获取 `accessToken`;**文档不提供任何真实凭据** | +| 限流 | 当前未设置接口级限流 | + +> `accessToken` 有效期 24h。Token 只通过登录流程获得,切勿在文档、代码或日志中硬编码或打印真实 Token。 + +### 12.2 请求参数 + +- **无 Path 参数**:路径不含任何 `{id}` 等占位符。 +- **无 Query 参数**:不读取任何查询串参数。 +- **无 Body**:不解析请求体。 +- **不接受身份参数**:身份仅从 JWT / SecurityContext 获取,`userId`、`studentUserId` 等入参一律忽略。 + - 例如请求 `GET /api/v1/profile/me?userId=999`,后端仍返回 **当前 JWT 身份** 对应的资料,`userId=999` **不会改变** JWT 身份。 + +### 12.3 角色权限说明 + +| 调用方 | HTTP | body.code | body.message | +|--------|------|-----------|--------------| +| STUDENT | 200 | `200` | `ok` | +| COMPANY | 403 | `2002` | `forbidden` | +| ADMIN | 403 | `2002` | `forbidden` | +| 未登录 / Token 缺失 / 非法 / 过期 | 401 | `401` | `not authenticated` | + +### 12.4 成功响应示例(有资料) + +```json +{ + "code": 200, + "message": "ok", + "data": { + "id": 1, + "userId": 2, + "realName": "张三", + "education": "本科", + "school": "某某大学", + "graduationYear": 2026, + "resumeUrl": "/uploads/resumes/2/resume.pdf", + "skills": "[Java, Spring Boot]", + "createdAt": "2026-08-24T10:00:00" + }, + "timestamp": "2026-08-24T10:00:00Z" +} +``` + +> 成功 `code=200`(**不是 0**)。`null` 字段不序列化。 + +### 12.5 空资料响应示例 + +```json +{ + "code": 200, + "message": "ok", + "data": {}, + "timestamp": "2026-08-24T10:00:00Z" +} +``` + +- `data={}` 是**空对象,不是 `null`**。 +- 有资料查询也**不会**返回 HTTP 404。 +- 不会自动创建 `student_profiles` 记录,不修改数据库。 +- 前端必须对 `data` 进行**空对象判定**(`Object.keys(data).length === 0`),而非仅判非 null。 + +### 12.6 401 响应示例 + +```json +{ + "code": 401, + "message": "not authenticated", + "timestamp": "2026-08-24T10:00:00Z" +} +``` + +### 12.7 403 响应示例 + +```json +{ + "code": 2002, + "message": "forbidden", + "data": null, + "timestamp": "2026-08-24T10:00:00Z" +} +``` + +### 12.8 业务码说明表 + +| 场景 | HTTP | code | message | +|------|------|------|---------| +| 成功 | 200 | `200` | `ok` | +| 未登录 / Token 无效 | 401 | `401` | `not authenticated` | +| 非 STUDENT 角色 | 403 | `2002` | `forbidden` | + +> - 成功码是 `200`,**不要写成 `0`**。 +> - 本接口未登录错误为 `401`,**不要写成 `2001`**。运行时认证入口实际返回 `code=401`,以运行时契约为准。 + +### 12.9 响应字段表(StudentProfileResponse,null 不输出) + +| 字段 | 类型 | 是否返回 | 说明 | 示例 | +|------|------|----------|------|------| +| id | Long | 有资料时 | 学生资料 ID | `1` | +| userId | Long | 有资料时 | 当前登录用户 ID | `2` | +| realName | String | 可空 | 真实姓名 | `张三` | +| bio | String | 可空 | 个人简介 | — | +| location | String | 可空 | 所在地 | — | +| education | String | 可空 | 学历 | `本科` | +| school | String | 可空 | 学校 | `某某大学` | +| graduationYear | Short/Integer | 可空 | 毕业年份(四位) | `2026` | +| resumeUrl | String | 可空 | 简历地址 | `/uploads/resumes/2/resume.pdf` | +| skills | String | 可空 | 技能(字符串) | `[Java, Spring Boot]` | +| createdAt | date-time | 有资料时 | 创建时间 | `2026-08-24T10:00:00` | + +> 不返回 `passwordHash`、手机号、验证码、JWT、内部认证字段。 + +### 12.10 skills 特别说明 + +- 当前类型为 **`String`**,**不保证是合法的 JSON**。 +- 前端**不要无条件 `JSON.parse(skills)`**,解析失败会抛异常。 +- 示例 `"[Java, Spring Boot]"` 只是普通字符串内容,不代表数组表示法。 +- 后续若改为数组,必须作为**独立版本或兼容字段变更**处理,不能静默破坏现有消费方。 +- **本轮不修改 `skills` 类型,也不修改 PUT 语义。** + +### 12.11 resumeUrl 特别说明 + +- 值可能是**相对存储路径**或文件存储地址,**不保证是完整的 `http/https` URL**。 +- 前端**下载简历应调用 `GET /api/v1/profile/me/resume`**,不要直接拼接 `resumeUrl` 做请求地址。 + +### 12.12 graduationYear 特别说明 + +- 当前后端**没有实现**年份范围校验。 +- 文档**不得虚构**未实现的范围限制(如 1900–2100 之类)。 +- 前端若展示年份,可自行决定是否校验,但接口本身不做约束。 +## 13. 候选人反馈响应脱敏 + +候选人反馈接口保持原有路径、请求、权限和错误码不变: + +```text +POST /applications/{applicationId}/feedback +GET /applications/{applicationId}/feedback +PUT /applications/{applicationId}/feedback +DELETE /applications/{applicationId}/feedback +``` + +成功响应中的 `data` 仅包含以下业务字段:`id`、`applicationId`、`rating`、`content`、`tags`、`createdAt`、`updatedAt`。 + +学生只能查看自己的投递反馈;岗位所属企业可以创建、查看、修改和删除反馈;其他学生、其他企业和管理员不因用户 ID 巧合而获得访问权限。 + +响应示例: + +```json +{ + "code": 200, + "message": "ok", + "data": { + "id": 1, + "applicationId": 31, + "rating": 5, + "content": "沟通良好", + "tags": ["沟通良好"], + "createdAt": "2026-08-25T10:00:00", + "updatedAt": "2026-08-25T10:00:00" + } +} +``` + +前端不得依赖或透传以下字段;这些字段不会出现在反馈响应中: + +```text +companyUserId、studentUserId、phone、email、passwordHash、resumeUrl +``` + +`candidate_feedback` 数据表仍保留企业用户和学生用户关联字段供权限校验和评分聚合使用,但这些内部字段不属于 API 响应。CandidateFeedback 也不等同于结构化面试评分;面试评分将在独立模块中提供。 + +## 14. 隐私设置(Privacy Settings)联调 + +学生隐私设置用于控制简历可见性与联系方式可见性,仅 STUDENT 角色可用: + +```text +GET /profile/me/privacy +PUT /profile/me/privacy +``` + +- 身份仅从 JWT 获取,不接受 `userId` / `studentUserId` / `candidateId` 等参数。 +- GET 无记录时返回默认对象(`resumeVisibility=PRIVATE`、`contactVisible=false`、`version=0`),不写库。 +- PUT 执行 upsert,同一学生始终一行;`version` 冲突返回 `409 / 9201`。 +- COMPANY / ADMIN → `403 / 2002`;未登录 → `401`。 +- 企业候选人详情/列表接口保持原脱敏白名单,不返回 `resumeUrl` 与联系方式,本轮不新增联系方式字段。 + +详见 [docs/privacy-settings-api.md](privacy-settings-api.md)。 + +## 15. 账号注销(Account Deletion)联调 + +当前登录用户可对自己的账号发起注销申请、查询状态、在冷静期内取消,适用于 STUDENT / COMPANY / ADMIN: + +```text +POST /profile/me/deletion # 申请注销(NONE → PENDING;PENDING 幂等 200;COMPLETED → 409/9301) +GET /profile/me/deletion # 查询状态(无记录返回默认 NONE,不写库) +POST /profile/me/deletion/cancel # 取消注销(仅 PENDING 且未过期;PENDING → NONE) +``` + +- 身份仅从 JWT 获取,不接受 `userId` / `candidateId` 等参数;未登录 → `401`。 +- **状态机**:NONE → PENDING → COMPLETED(终态),可在冷静期内取消回 NONE。 +- **冷静期**:默认 30 天(`app.deletion.cooling-days` / `DELETION_COOLING_DAYS` 可配置)。 +- **PENDING 写限制**:冷静期内 POST/PUT/PATCH/DELETE 业务写操作返回 `409 / 9302`;GET、OPTIONS 及三个注销接口放行。 +- **COMPLETED 旧 JWT**:即使 JWT 未过期也拒绝普通业务请求(`409 / 9303`);登录时亦被阻止。 +- **脱敏**:仅脱敏 `users` 表 PII(phone→`deleted_{id}`、email/passwordHash/avatarUrl 置空、nickname→`deleted`、is_active=false),**不删除任何业务数据**。 +- **原手机号禁止重新注册**:注销完成后仅保存原手机号的 HMAC-SHA256 摘要(`deleted_phone_hash`,密钥 `app.deletion.phone-hash-secret`),不存明文;原手机号再次登录/注册返回 `409 / 9303`,且不会自动创建新账号。 +- **懒转换**:到期 PENDING 首次 `GET /profile/me/deletion` 即返回 `COMPLETED`(独立事务重读最新记录),不会出现先 PENDING 后 COMPLETED 的不一致。 +- **ADMIN**:最后一个有效 ADMIN 不允许注销 → `409 / 9306`。 +- 错误码 `9301-9306`(详见 [docs/account-deletion-api.md](account-deletion-api.md))。 + +前端建议:发起注销与取消前先 `GET /profile/me/deletion` 确认当前状态;处于 PENDING 时应禁用业务提交类按钮并提示冷静期到期时间。 + +## 16. 个人数据导出(Personal Data Export)联调 + +**这不是简历导出**(简历导出走 `/resume-export-tasks`,两者独立)。个人数据导出把当前登录用户的全量业务数据异步打包成 ZIP 供本人下载,适用于 STUDENT / COMPANY / ADMIN: + +```text +POST /profile/me/data-export-tasks # 创建导出任务(异步,返回 PENDING) +GET /profile/me/data-export-tasks?page=0&size=20 # 本人任务列表(倒序分页) +GET /profile/me/data-export-tasks/{taskId} # 本人任务详情(状态/大小等) +GET /profile/me/data-export-tasks/{taskId}/download # 下载已完成 ZIP(需 JWT) +``` + +- **异步**:创建后立即返回 `PENDING`;后台 Worker 打包完成后变为 `COMPLETED` 才可下载。 +- **下载需 JWT**:下载地址不能脱离 JWT 单独访问;不提供公开 URL。 +- **文件有效期**:默认 24 小时(`app.data-export.file-ttl-hours`),过期状态为 `EXPIRED` 且不可再下载。 +- **导出范围/脱敏**:仅包含本人数据;不含他人手机号/邮箱/简历地址、不含 `passwordHash`/JWT/短信验证码/`deletedPhoneHash`、管理员内部审核字段、系统异常堆栈、服务器绝对路径与连接串。会话对方仅保留 `participantId`。详见 [docs/personal-data-export-api.md](personal-data-export-api.md)。 +- **错误**:未登录 `401`;访问他人任务 `403/2002`;任务不存在 `404/9401`;存在未完成任务 `409/9402`;文件未就绪 `409/9403`;已过期 `409/9404`;失败 `409/9405`;文件缺失 `409/9406`。 +- **注销限制**:注销 PENDING 禁止创建(`409/9302`);注销 COMPLETED 所有导出接口返回 `409/9303`。 + +前端建议:创建后轮询任务详情直到 `status=COMPLETED` 且 `downloadAvailable=true` 再展示下载按钮;处于注销 PENDING/COMPLETED 时应隐藏「导出我的数据」入口。 + +## 17. 结构化面试评分(Structured Interview Score)联调 + +**这不是 candidate_feedback**(候选人反馈是另一模块,两者独立;本模块是结构化维度评分)。企业为已完成(`COMPLETED`)的面试填写并提交评分,学生仅能在提交后查看自己的评分与历史: + +```text +PUT /interviews/{interviewId}/score # 企业 owner 保存草稿(幂等更新,DRAFT) +GET /interviews/{interviewId}/score # 查看评分(企业 see DRAFT/SUBMITTED;学生仅 SUBMITTED) +POST /interviews/{interviewId}/score/submit # 提交草稿 → SUBMITTED(不可逆) +GET /interviews/{interviewId}/score/history # 评分快照历史(无记录返回 200 空数组) +``` + +- **五个维度**:`technicalScore`、`communicationScore`、`problemSolvingScore`、`roleFitScore`、`professionalismScore`,每项 1-5。 +- **草稿**:PUT 时五维可留空;重复 PUT 更新同一行(`version` 递增)。 +- **提交**:五维全部非空,否则 `400/1001`;`overallScore` 由服务端按 `(t+c+ps+rf+p)/5` 计算(两位小数 HALF_UP),**客户端不要传**,传了也会被忽略。 +- **recommendation**(可选):`STRONG_HIRE` / `HIRE` / `NEUTRAL` / `NO_HIRE`,企业手动选择,不由 overallScore 推导。 +- **权限**:写入仅岗位所属企业 owner(COMPANY);ADMIN 不可访问;STUDENT 不可写。学生只能看自己的 `SUBMITTED`,查看他人或 DRAFT 返回 `403/8402`。 +- **状态机**:无评分 → `DRAFT` → `SUBMITTED`。`SUBMITTED` 后再次 PUT `409/9503`;重复 submit 幂等返回原评分(不新增历史、不重复通知)。 +- **通知**:提交成功仅通知目标学生,`businessType=INTERVIEW`,`businessId=interviewId`;草稿与重复提交不发通知;企业自身不通知;无 Push。 +- **错误码**:`9501` 并发冲突(409)、`9502` 面试未完成(409)、`9503` 已提交(409)、`9504` 无评分(404);归属非法 `8402`、未被授权 `2002`、面试不存在 `8401`。 +- **脱敏**:响应不返回 `companyUserId`/`studentUserId`/`evaluatorUserId`/手机号/邮箱。 +- **历史脱敏**:`GET /score/history` 不返回 `actorUserId`,仅 `actorRole`。 +- 面试本身必须为 `COMPLETED` 才能评分(否则 `409/9502`)。 + +前端建议:企业端保存草稿用 PUT;「提交」用 POST submit 并在按钮上二次确认(不可逆);学生端仅在评分状态为 `SUBMITTED` 时展示。API 详见 [docs/interview-score-api.md](interview-score-api.md)。 + +## 18. 同岗位竞争力排名(Competition Ranking)联调 + +学生查看自己在某个 **已投递且为 PUBLISHED** 岗位候选人池中的竞争力排名(匿名): + +```text +GET /jobs/{jobId}/competition-ranking # 仅 STUDENT 且已投递该岗位 +``` + +- **权限矩阵**: + - 未登录 → `401`(安全过滤器) + - `COMPANY` / `ADMIN` → `403/2002` + - STUDENT 但未投递该岗位 → `403/2002` + - **STUDENT 唯一投递为 `WITHDRAWN` 或 `REJECTED` → `403/2002`**:投递已退出候选人池,与 cohort 排除口径一致,不能再查看排名。 + - `jobId <= 0` → `400/1001` + - 岗位不存在或状态非 `PUBLISHED`(如 `DRAFT`/`CLOSED`)→ `404/7001` +- **cohort**:该岗位非 `WITHDRAWN`、非 `REJECTED` 的所有投递;`SUBMITTED/VIEWED/INTERVIEW/HIRED` 计入;同一学生同一岗位由数据库唯一约束保证仅一条。无有效评分的候选人**仍计入 `cohortSize`**,但不参与百分位,排在末尾。 +- **综合分**:复用 `CandidateScoringCalculator` 真实公式(仅统计 `OK/ESTIMATED` 维度,`PENDING/INSUFFICIENT_DATA` 不折算为 0),取每人 `period <= 当前月` 的**最新**快照。 +- **百分位**:严格复用企业评分看板公式 `lower / (scored - 1) * 100`;有分候选人数 `< 2` 时 `percentile = null`;未取整为 2 位小数 HALF_UP。 +- **本人无有效评分**:`rank`、`overallScore`、`percentile` 均为 `null`(仍返回 `cohortSize`)。 +- **响应结构(示例)**: + +```json +{ + "jobId": 9, "rank": 2, "cohortSize": 8, "overallScore": 77.50, + "percentile": 66.67, "status": "PARTIAL", + "asOfPeriod": "2026-08", "mySnapshotPeriod": "2026-08", + "dimensions": [ { "dimension": "EDUCATION", "score": 80, "status": "OK", "weight": 12.50 } ], + "gaps": [], + "topCandidates": [ { "rank": 1, "overallScore": 88.50 } ], + "rulesVersion": "candidate-scoring-v0.1", "generatedAt": "2026-08-26T10:00:00Z" +} +``` + +- **脱敏**:`topCandidates` 固定返回 Top 5、每项**仅含 `rank` 与 `overallScore`**,不含 `studentUserId`/`candidateId`/`name`/`school`/`phone`/`email`/`resumeUrl`/`applicationId` 等任何身份字段;响应整体也不含 `candidateId`。不提供 `top` 查询参数。 +- **只读**:该接口为 `GET`,不写任何业务表;身份仅取自 JWT,不接收 `userId/studentUserId/candidateId` 参数。 +- 规则版本固定 `rulesVersion = "candidate-scoring-v0.1"`;`asOfPeriod` 为计算截止月(当前月),`mySnapshotPeriod` 为当前学生实际使用的快照月(若与他人周期不同,二者可不同)。 +- API 详见 [docs/competition-ranking-api.md](competition-ranking-api.md)。 + +## 19. 举报与屏蔽(Report & Block)联调 + +提供举报、屏蔽与 ADMIN 举报处理能力。**身份一律取 JWT,不接受 `userId/reporterId/reportedUserId` 参数**。 + +**举报(用户端)** + +```text +POST /reports # 提交举报 +GET /reports/me # 我的举报列表(status/page/size) +GET /reports/{id} # 举报详情(本人或 ADMIN) +``` + +- `targetType=USER` 必填 `targetUserId`;`targetType=MESSAGE` 必填 `messageId`。`reportedUserId` 由服务端推导,不传。 +- `category` ≤40 字符;`reason` 1–1000 字符;`evidenceUrls` 可选(≤5 个,仅 http/https,每个 ≤500 字符);`idempotencyKey` 可选(≤64 字符)。 +- **幂等**:同 `idempotencyKey` + 相同请求体重放返回原举报 `200`;同 key 不同请求体 → `409/9606`;同目标同分类已有 PENDING → `409/9603`。 +- **限流**:每用户每小时最多 10 次 `POST /reports`,超限 → `429/9609`。 +- **MESSAGE 举报**:无权访问该消息 → `403/2002`(不泄露消息是否存在);发送者由服务端推导,禁止客户端注入。 +- 举报自己 → `400/9602`;不存在目标 → 复用用户不存在错误码。 + +**屏蔽(用户端)** + +```text +PUT /blocks/{blockedUserId} # 屏蔽 +DELETE /blocks/{blockedUserId} # 解除(幂等) +GET /blocks/me # 我的屏蔽列表(page/size) +``` + +- 屏蔽自己 → `400/9608`;PUT 已存在返回 200 不新增;DELETE 不存在返回 200 不报错。 +- 屏蔽仅影响**站内信**:禁止与对方新建 DIRECT 会话、禁止向对方发送新消息(`403/2002`,模糊提示“message cannot be sent”);历史消息仍可读,且不影响系统通知、投递、Offer、面试、评分、评价。 + +**ADMIN 举报管理** + +```text +GET /admin/reports # 举报列表(status/targetType/category/page/size) +GET /admin/reports/{id} # 举报详情 +POST /admin/reports/{id}/decision # 处理决定 +``` + +- 仅 ADMIN;COMPANY/STUDENT/未登录分别 `403/403/401`。 +- `decision` 仅 `RESOLVED`/`REJECTED`;REJECTED 必填 `note`;重复决定 → `409/9604`;非法流转 → `409/9605`。 +- 处理不自动封禁、不删除消息、不修改投递/面试/评分状态;处理后向举报人发送一条 `REPORT_DECISION` 通知。 + +**脱敏**:所有举报/屏蔽响应均**不返回** phone、email、passwordHash、resumeUrl、JWT、消息全文;MESSAGE 举报仅返回脱敏 `messagePreview`;举报列表不返回举报人内部 ID。 + +- API 详见 [docs/report-block-api.md](report-block-api.md)。 diff --git a/docs/interview-score-api.md b/docs/interview-score-api.md new file mode 100644 index 0000000..a09efe5 --- /dev/null +++ b/docs/interview-score-api.md @@ -0,0 +1,211 @@ +# 结构化面试评分 API(V31) + +本文档描述「结构化面试评分」模块。**这与候选人反馈(`candidate_feedback`)是两套完全独立的机制**,请勿混淆: + +- candidate_feedback / Candidate Scoring Engine:既有的候选人反馈与评分治理模块,语义保持不变。 +- 结构化面试评分:企业针对某一场**已完成(`COMPLETED`)**的面试,按固定五个维度填写并提交评分。独立落库到 `interview_scores` / `interview_score_history` 两张新表,不写 candidate_feedback。 + +只支持单面试官评分模型(一场面试一条评分);不新增多面试官模型。 + +OpenAPI 路径总数 113 → **116**(3 个 path、4 个 operation)。 + +--- + +## 一、接口总览 + +| 操作 | 路径 | 说明 | +| --- | --- | --- | +| PUT | `/interviews/{interviewId}/score` | 企业 owner 保存评分草稿(幂等更新) | +| GET | `/interviews/{interviewId}/score` | 查看评分(企业见 DRAFT/SUBMITTED;学生仅 SUBMITTED) | +| POST | `/interviews/{interviewId}/score/submit` | 提交草稿 → SUBMITTED(不可逆) | +| GET | `/interviews/{interviewId}/score/history` | 评分快照历史(无记录返回 200 空数组) | + +### 身份与权限 + +- 所有接口都需要登录(JWT);身份只从 JWT/SecurityContext 获取,**不接受 `userId`/`studentUserId`/`companyId` 参数**。 +- 归属校验:`companyRepository.findByOwnerUserId(userId)` 解析企业;`interview.companyUserId == company.ownerUserId` 且经由 application→job 的 `job.companyId == company.id` 且 `application.studentUserId == interview.studentUserId`,否则 `403/8402`。 +- 企业 owner(COMPANY):可写可读草稿与已提交。 +- 目标学生(STUDENT):只能查看**已提交(SUBMITTED)**的评分;查看 DRAFT 或他人评分返回 `403/8402`。 +- ADMIN:不允许访问(`403/8402`)。 +- 面试不存在 `404/8401`。 + +--- + +## 二、保存评分草稿 + +`PUT /interviews/{interviewId}/score` + +请求体: + +```json +{ + "technicalScore": null, + "communicationScore": 4, + "problemSolvingScore": 3, + "roleFitScore": null, + "professionalismScore": 5, + "recommendation": "HIRE", + "comment": "综合素质较好" +} +``` + +字段: + +- 五个维度 `technicalScore` / `communicationScore` / `problemSolvingScore` / `roleFitScore` / `professionalismScore`,取值 1-5;草稿阶段可留空(`null`)。 +- `recommendation`(可选):`STRONG_HIRE` / `HIRE` / `NEUTRAL` / `NO_HIRE`,企业手动选择,不由 overallScore 推导;非法值 `400/1001`。 +- `comment`(可选):≤2000 字符。 +- `overallScore`:**DTO 中不提供该字段**,由服务端计算并忽略客户端传入值。 + +约束: + +- 无评分时创建 `DRAFT`;已有 DRAFT 时更新同一行(`@Version` 递增)。 +- `SUBMITTED` 后再次 PUT → `409/9503`。 +- 保存草稿不写历史节点(历史仅写 `NULL→DRAFT`、`DRAFT→SUBMITTED` 两次),但写审计日志 `INTERVIEW_SCORE_SAVED`。 +- 并发冲突 → `409/9501`。 + +成功响应(脱敏): + +```json +{ + "code": 200, + "message": "ok", + "data": { + "id": 1, + "interviewId": 10, + "applicationId": 20, + "status": "DRAFT", + "technicalScore": null, + "communicationScore": 4, + "problemSolvingScore": 3, + "roleFitScore": null, + "professionalismScore": 5, + "overallScore": null, + "recommendation": "HIRE", + "comment": "综合素质较好", + "submittedAt": null, + "version": 1, + "createdAt": "2026-08-26T10:00:00", + "updatedAt": "2026-08-26T10:00:00" + } +} +``` + +> 脱敏:响应不返回 `companyUserId` / `studentUserId` / `evaluatorUserId` / 手机号 / 邮箱。 + +--- + +## 三、查看评分 + +`GET /interviews/{interviewId}/score` + +- 企业 owner:可看 `DRAFT` 与 `SUBMITTED`。 +- 目标学生:仅可看 `SUBMITTED`;查看 DRAFT → `403/8402`。 +- 无评分 → `404/9504`。 +- 面试未完成 → `409/9502`。 + +--- + +## 四、提交评分 + +`POST /interviews/{interviewId}/score/submit` + +无请求体。将 `DRAFT` 提交为 `SUBMITTED`: + +- 五个维度必须全部非空,否则 `400/1001`。 +- `overallScore` 由服务端计算:`(t + c + ps + rf + p) / 5`,`BigDecimal`,保留两位小数,`RoundingMode.HALF_UP`。 +- 提交成功后 `submittedAt` 落库;不可逆。 +- 写历史节点 `DRAFT→SUBMITTED`;写审计日志 `INTERVIEW_SCORE_SUBMITTED`。 +- **幂等**:重复 submit 已 SUBMITTED → `200` 返回原评分;不新增历史、不重复通知。 +- **通知**:仅通知目标学生一次,`businessType=INTERVIEW`、`businessId=interviewId`、幂等键 `interview-score-submitted-{interviewId}`;草稿不发通知;不通知企业自身;无 Push。 +- 并发冲突 → `409/9501`。 + +--- + +## 五、查看评分历史 + +`GET /interviews/{interviewId}/score/history` + +- 企业 owner:可查看全部历史。 +- 目标学生:仅在评分 `SUBMITTED` 后可查看。 +- 无历史记录 → `200` 空数组 `items=[]`。 +- 历史快照 `snapshot` 为解析后的维度分 + `overallScore`(JSON 字符串落库于 `snapshot_text` TEXT 字段,不返回内部用户标识)。 + +响应示例: + +```json +{ + "code": 200, + "message": "ok", + "data": [ + { + "id": 1, + "scoreId": 1, + "interviewId": 10, + "fromStatus": null, + "toStatus": "DRAFT", + "actorRole": "COMPANY", + "snapshot": { "technicalScore": null, "overallScore": null }, + "createdAt": "2026-08-26T10:00:00" + }, + { + "id": 2, + "scoreId": 1, + "interviewId": 10, + "fromStatus": "DRAFT", + "toStatus": "SUBMITTED", + "actorRole": "COMPANY", + "snapshot": { "technicalScore": 4, "overallScore": 4.20 }, + "createdAt": "2026-08-26T10:05:00" + } + ] +} +``` + +> 脱敏:历史条目不返回 `actorUserId`、手机号、邮箱。 + +--- + +## 六、状态机与历史 + +``` +无评分 → DRAFT → SUBMITTED +``` + +- PUT 无评分 → 创建 `DRAFT`(写历史 `NULL→DRAFT`)。 +- PUT 已有 DRAFT → 更新同一行,不写历史,写审计 `INTERVIEW_SCORE_SAVED`。 +- POST submit:DRAFT → SUBMITTED(写历史 `DRAFT→SUBMITTED`,写审计 `INTERVIEW_SCORE_SUBMITTED`)。 +- `SUBMITTED` 不可逆;重复 submit 幂等。 +- 草稿每次 PUT `version` 递增;`@Version` 乐观锁防并发。 + +--- + +## 七、错误码 + +| 错误码 | 含义 | HTTP | +| --- | --- | --- | +| 9501 | 并发冲突(更新或首次并发创建撞唯一约束) | 409 | +| 9502 | 面试未完成(非 COMPLETED),不可评分 | 409 | +| 9503 | 评分已提交,不可再修改 | 409 | +| 9504 | 评分不存在 | 404 | +| 8401 | 面试不存在 | 404 | +| 8402 | 归属校验失败 / 越权访问 | 403 | +| 2002 | 非 COMPANY(对写入),未授权访问 | 403 | +| 1001 | 参数校验失败(非 1-5 维度、非法推荐值、提交缺维度) | 400 | + +--- + +## 八、数据库(V31) + +新增两张表,均 `ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`: + +- `interview_scores`:`interview_id` 唯一;五个评分字段允许 NULL 且在 1-5;`overall_score` 1.00-5.00;`status` CHECK(DRAFT/SUBMITTED);`recommendation` CHECK;`SUBMITTED` 时五维、`overallScore`、`submittedAt` 均非空;`@Version`;外键 `ON DELETE CASCADE`。 +- `interview_score_history`:`snapshot_text TEXT`(不使用 JSON 类型,避免 MySQL/H2 兼容问题);外键 `ON DELETE CASCADE`。 + +不修改 V1-V30、不修改 CandidateFeedback、不修改 Interview 状态机、不修改 Candidate Scoring Engine、不修改个人数据导出/账号注销/隐私设置。 + +--- + +## 九、文档与验证 + +- OpenAPI 路径数 113 → 116;`docs/openapi-rc.json` 已同步。 +- 验证:`python docs/verify_openapi.py`;项目 `mvn test`、`mvn clean verify`。 \ No newline at end of file diff --git a/docs/offer-advance-api.md b/docs/offer-advance-api.md new file mode 100644 index 0000000..1d38606 --- /dev/null +++ b/docs/offer-advance-api.md @@ -0,0 +1,266 @@ +# Offer 进阶模块接口说明 + +> 职来 (mydreamjob) · Offer 进阶能力 +> 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer ` +> 在 [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**。 \ No newline at end of file diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 11eb711..f046158 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -1 +1,9385 @@ -{"openapi": "3.1.0", "info": {"title": "OpenAPI definition", "version": "v0"}, "servers": [{"url": "http://localhost", "description": "Generated server url"}], "tags": [{"name": "岗位收藏", "description": "学生收藏/取消收藏岗位,查看收藏列表"}, {"name": "API", "description": "API 信息入口"}, {"name": "认证", "description": "登录、验证码、用户信息"}, {"name": "候选人反馈", "description": "企业对已投递候选人的评价反馈"}, {"name": "通知", "description": "站内通知查询、已读管理"}, {"name": "双向匹配沟通", "description": "匹配成功后创建或获取直聊会话(幂等)"}, {"name": "企业认证", "description": "企业认证提交、审核管理"}, {"name": "投递", "description": "学生投递、撤回、查询;企业查看和审核投递;时间线"}, {"name": "双向匹配", "description": "求职者意愿分、企业 HR 适配分、匹配状态"}, {"name": "系统", "description": "健康检查、应用信息"}, {"name": "Offer", "description": "企业录用邀请:创建、发送、接受/拒绝/洽谈、到期失效、提醒与时间线"}, {"name": "岗位", "description": "岗位创建、管理、查询"}, {"name": "个人资料", "description": "学生个人资料查询、更新与简历管理"}, {"name": "评价审核", "description": "评价审核与防刷:待审列表、审核决策、审核历史(仅 ADMIN)"}, {"name": "简历版本", "description": "学生简历版本管理、默认简历、导出任务"}, {"name": "双向匹配阈值", "description": "企业与求职者的匹配阈值配置"}, {"name": "岗位匹配", "description": "学生查看岗位匹配诊断(确定性规则)"}, {"name": "面试", "description": "面试创建、查看、响应、取消、完成"}, {"name": "求职偏好", "description": "学生求职偏好查询与保存"}, {"name": "消息", "description": "会话创建、消息发送、已读管理"}, {"name": "岗位评价", "description": "学生对已发布岗位进行评价"}, {"name": "企业", "description": "企业资料创建、查询与更新"}, {"name": "求职者评分卡", "description": "求职者评价聚合与评分卡"}], "paths": {"/profile/me": {"get": {"tags": ["个人资料"], "summary": "查询当前学生资料", "description": "未创建资料时返回 200 空对象,不自动创建", "operationId": "getMyProfile", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["个人资料"], "summary": "更新学生资料", "description": "仅更新传入的非 null 字段;不存在则自动创建", "operationId": "updateMyProfile", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/StudentProfileUpdateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/profile/me/review-weights": {"get": {"tags": ["student-review-weight-controller"], "operationId": "getMyWeights", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseReviewWeightResponse"}}}}, "401": {"description": "未登录", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}, "403": {"description": "非 STUDENT 角色", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}}}, "put": {"tags": ["student-review-weight-controller"], "operationId": "saveWeights", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SaveWeightRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseReviewWeightResponse"}}}}, "401": {"description": "未登录", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}, "403": {"description": "非 STUDENT 角色", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}}}}, "/profile/me/resume-versions/{id}": {"get": {"tags": ["简历版本"], "summary": "简历版本详情", "description": "仅本人可查看;他人 403,不存在 404", "operationId": "get", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["简历版本"], "summary": "修改简历版本", "description": "本人;可选重命名 + 可选替换文件(替换时删除旧文件)", "operationId": "update", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, {"name": "name", "in": "query", "required": false, "schema": {"type": "string"}}], "requestBody": {"content": {"multipart/form-data": {"schema": {"type": "object", "properties": {"file": {"type": "string", "format": "binary"}}}}}}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "delete": {"tags": ["简历版本"], "summary": "删除简历版本", "description": "默认版本需先设其他默认;导出进行中不可删;删除文件并移除记录", "operationId": "delete", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/profile/me/preferences": {"get": {"tags": ["求职偏好"], "summary": "查询求职偏好", "description": "仅 STUDENT;无记录时返回空偏好对象", "operationId": "getPreferences", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["求职偏好"], "summary": "保存求职偏好", "description": "仅 STUDENT;upsert,完全替换传入字段", "operationId": "savePreferences", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/StudentPreferenceRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/profile/me/match-rule": {"get": {"tags": ["双向匹配阈值"], "summary": "查询学生匹配阈值", "description": "未配置返回默认 60", "operationId": "getStudentRule", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["双向匹配阈值"], "summary": "更新学生匹配阈值", "description": "requiredHrScore 0-100;首次更新创建配置", "operationId": "updateStudentRule", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/StudentMatchRuleRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/offers/{id}": {"get": {"tags": ["Offer"], "summary": "Offer 详情", "description": "仅目标候选人、所属企业可访问;其他账号返回无权限错误", "operationId": "get_1", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["Offer"], "summary": "修改 Offer", "description": "仅所属企业、仅 DRAFT 状态可修改;其余状态返回业务错误", "operationId": "update_1", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/OfferUpdateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/{jobId}": {"get": {"tags": ["岗位"], "summary": "查询岗位详情", "description": "企业可查看自己管理的岗位详情", "operationId": "getJob", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["岗位"], "summary": "更新岗位", "description": "仅岗位所属企业可修改", "operationId": "updateJob", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/JobUpdateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/{jobId}/reviews/{reviewId}": {"get": {"tags": ["岗位评价"], "summary": "岗位评价详情", "description": "公开访问;返回单条 PUBLISHED 评价与八维评分", "operationId": "get_2", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, {"name": "reviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["岗位评价"], "summary": "修改评价", "description": "仅评价本人可以修改", "operationId": "update_2", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, {"name": "reviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/JobReviewUpdateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "delete": {"tags": ["岗位评价"], "summary": "删除评价", "description": "仅评价本人可以删除", "operationId": "delete_1", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, {"name": "reviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/interviews/{interviewId}": {"get": {"tags": ["面试"], "summary": "面试详情", "description": "学生本人或企业可查看面试详情", "operationId": "get_3", "parameters": [{"name": "interviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["面试"], "summary": "修改面试", "description": "企业修改面试时间、地点等信息,仅 SCHEDULED/CONFIRMED 状态可修改", "operationId": "update_3", "parameters": [{"name": "interviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/InterviewUpdateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/companies/me": {"get": {"tags": ["企业"], "summary": "查询当前企业资料", "description": "返回当前登录用户绑定的企业资料,未绑定企业返回 404", "operationId": "getMyCompany", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["企业"], "summary": "更新企业资料", "description": "仅更新传入的非 null 字段;未绑定企业时返回 404", "operationId": "updateMyCompany", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompanyUpdateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "post": {"tags": ["企业"], "summary": "创建企业资料", "description": "仅 COMPANY 角色可调用;已存在时返回 409", "operationId": "createMyCompany", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompanyCreateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/companies/me/match-rule": {"get": {"tags": ["双向匹配阈值"], "summary": "查询企业匹配阈值", "description": "未配置返回默认 60", "operationId": "getCompanyRule", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["双向匹配阈值"], "summary": "更新企业匹配阈值", "description": "requiredCandidateScore 0-100;首次更新创建配置", "operationId": "updateCompanyRule", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CompanyMatchRuleRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{id}/hr-suitability-score": {"put": {"tags": ["双向匹配"], "summary": "提交企业 HR 适配分", "description": "仅岗位所属企业;0-100;MATCHED/CLOSED 后不可修改", "operationId": "submitHrSuitabilityScore", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScoreRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{id}/candidate-interest-score": {"put": {"tags": ["双向匹配"], "summary": "提交求职者岗位意愿分", "description": "仅投递本人(STUDENT);0-100;MATCHED/CLOSED 后不可修改", "operationId": "submitCandidateInterestScore", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScoreRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{applicationId}/feedback": {"get": {"tags": ["候选人反馈"], "summary": "查询候选人反馈", "description": "该投递的企业或学生可查看", "operationId": "get_4", "parameters": [{"name": "applicationId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "put": {"tags": ["候选人反馈"], "summary": "修改候选人反馈", "description": "仅岗位所属企业", "operationId": "update_4", "parameters": [{"name": "applicationId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CandidateFeedbackRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "post": {"tags": ["候选人反馈"], "summary": "创建候选人反馈", "description": "仅岗位所属企业;同一投递只能反馈一次", "operationId": "create", "parameters": [{"name": "applicationId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CandidateFeedbackRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "delete": {"tags": ["候选人反馈"], "summary": "删除候选人反馈", "description": "仅岗位所属企业", "operationId": "delete_2", "parameters": [{"name": "applicationId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/profile/me/resume": {"get": {"tags": ["个人资料"], "summary": "下载简历", "description": "下载当前学生上传的简历文件,仅 STUDENT 角色", "operationId": "downloadResume", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"type": "string", "format": "byte"}}}}}}, "post": {"tags": ["个人资料"], "summary": "上传简历", "description": "仅 STUDENT 角色;PDF/DOC/DOCX,≤10MB;上传新简历时替换旧文件", "operationId": "uploadResume", "requestBody": {"content": {"multipart/form-data": {"schema": {"type": "object", "properties": {"file": {"type": "string", "format": "binary"}}, "required": ["file"]}}}}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "delete": {"tags": ["个人资料"], "summary": "删除简历", "description": "删除当前学生的简历文件并清空 resumeUrl,仅 STUDENT 角色", "operationId": "deleteResume", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/profile/me/resume-versions": {"get": {"tags": ["简历版本"], "summary": "简历版本列表", "description": "本人 ACTIVE 版本,version_no 倒序", "operationId": "list", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListMapStringObject"}}}}}}, "post": {"tags": ["简历版本"], "summary": "创建简历版本", "description": "仅本人(STUDENT);PDF/DOC/DOCX ≤10MB;可附版本名", "operationId": "create_1", "parameters": [{"name": "name", "in": "query", "required": false, "schema": {"type": "string"}}], "requestBody": {"content": {"multipart/form-data": {"schema": {"type": "object", "properties": {"file": {"type": "string", "format": "binary"}}, "required": ["file"]}}}}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/profile/me/resume-versions/{id}/export": {"post": {"tags": ["简历版本"], "summary": "创建简历导出任务", "description": "异步复制该版本文件到导出目录;返回任务状态", "operationId": "createExportTask", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/profile/me/resume-versions/{id}/default": {"post": {"tags": ["简历版本"], "summary": "设为默认简历", "description": "同用户最多一个默认;已默认幂等;并发冲突 409", "operationId": "setDefault", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/offers": {"get": {"tags": ["Offer"], "summary": "Offer 列表", "description": "候选人查看收到的、企业查看发出的 Offer;支持 status、applicationId 筛选与分页", "operationId": "list_1", "parameters": [{"name": "status", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "applicationId", "in": "query", "required": false, "schema": {"type": "integer", "format": "int64"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "post": {"tags": ["Offer"], "summary": "创建 Offer", "description": "仅企业账号;投递存在、岗位属于本企业、投递状态可发、无有效 Offer;默认 DRAFT", "operationId": "create_2", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/OfferCreateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/offers/{id}/remind": {"post": {"tags": ["Offer"], "summary": "手动提醒", "description": "仅所属企业;仅 SENT/NEGOTIATING 可提醒;相同提醒不重复创建", "operationId": "remind", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/notifications/{id}/read": {"post": {"tags": ["通知"], "summary": "标记已读", "description": "标记单条通知已读(幂等)", "operationId": "markRead", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/notifications/read-all": {"post": {"tags": ["通知"], "summary": "全部已读", "description": "标记当前用户全部通知已读(幂等)", "operationId": "markAllRead", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs": {"get": {"tags": ["岗位"], "summary": "查询已发布岗位列表", "description": "仅返回 PUBLISHED 岗位,支持关键词、城市、用工类型、薪资筛选和分页", "operationId": "listJobs", "parameters": [{"name": "keyword", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "city", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "employmentType", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "salaryMin", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32"}}, {"name": "salaryMax", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "post": {"tags": ["岗位"], "summary": "创建岗位", "description": "仅企业账号可调用,新岗位默认 DRAFT 状态", "operationId": "createJob", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/JobCreateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/{jobId}/reviews": {"get": {"tags": ["岗位评价"], "summary": "查询岗位评价列表", "description": "公开访问;仅返回 PUBLISHED 评价,分页;每条含八维评分", "operationId": "list_2", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "post": {"tags": ["岗位评价"], "summary": "创建岗位评价", "description": "仅 STUDENT;同一学生对同一岗位只能评价一次", "operationId": "create_3", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/JobReviewCreateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/{jobId}/favorite": {"post": {"tags": ["岗位收藏"], "summary": "收藏岗位", "description": "仅 STUDENT;仅 PUBLISHED;幂等", "operationId": "favorite", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "delete": {"tags": ["岗位收藏"], "summary": "取消收藏", "description": "仅 STUDENT;幂等,未收藏也返回 200", "operationId": "unfavorite", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/interviews/{interviewId}/respond": {"post": {"tags": ["面试"], "summary": "响应面试邀请", "description": "学生接受、拒绝或申请改期", "operationId": "respond", "parameters": [{"name": "interviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/InterviewRespondRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/interviews/{interviewId}/complete": {"post": {"tags": ["面试"], "summary": "完成面试", "description": "企业在面试时间结束后将面试标记为完成", "operationId": "complete", "parameters": [{"name": "interviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "additionalProperties": {"type": "string"}}}}}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/interviews/{interviewId}/cancel": {"post": {"tags": ["面试"], "summary": "取消面试", "description": "企业取消已安排的面试", "operationId": "cancel", "parameters": [{"name": "interviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/InterviewCancelRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/conversations": {"get": {"tags": ["消息"], "summary": "会话列表", "description": "当前用户参与的所有会话", "operationId": "listConversations", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListMapStringObject"}}}}}}, "post": {"tags": ["消息"], "summary": "创建/获取会话", "description": "创建或返回与另一用户的 DIRECT 会话", "operationId": "createConversation", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateConversationRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/conversations/{id}/read": {"post": {"tags": ["消息"], "summary": "标记已读", "description": "将当前会话所有消息标记为已读", "operationId": "markRead_1", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/conversations/messages": {"post": {"tags": ["消息"], "summary": "发送消息", "description": "在会话中发送消息,仅成员可发送", "operationId": "sendMessage", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SendMessageRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/companies/me/verification": {"get": {"tags": ["企业认证"], "summary": "查询企业认证状态", "description": "企业查询自己的认证状态和历史", "operationId": "getMyVerification", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}, "post": {"tags": ["企业认证"], "summary": "提交企业认证", "description": "企业提交认证材料(营业执照等),只能提交自己的认证", "operationId": "submit", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/VerificationSubmitRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/auth/sms/send": {"post": {"tags": ["认证"], "summary": "发送短信验证码(Mock)", "description": "开发环境 Mock:验证码会打印到日志", "operationId": "sendSmsCode", "parameters": [{"name": "phone", "in": "query", "required": true, "schema": {"type": "string", "minLength": 1, "pattern": "[0-9]{11}"}}, {"name": "purpose", "in": "query", "required": false, "schema": {"type": "string", "default": "login"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringString"}}}}}}}, "/auth/login": {"post": {"tags": ["认证"], "summary": "Mock 登录", "description": "使用手机号+验证码登录,返回 JWT token", "operationId": "mockLogin", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/LoginRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications": {"post": {"tags": ["投递"], "summary": "投递岗位", "description": "只能投递 PUBLISHED 状态的岗位,不可重复投递", "operationId": "apply", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApplicationCreateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{id}/withdraw": {"post": {"tags": ["投递"], "summary": "撤回投递", "description": "仅 SUBMITTED/VIEWED 状态可撤回,撤回后不可逆", "operationId": "withdraw", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{id}/conversation": {"post": {"tags": ["双向匹配沟通"], "summary": "创建/获取匹配会话", "description": "仅 MATCHED 双方可调用;重复调用返回同一会话", "operationId": "createOrGetConversation", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{applicationId}/interviews": {"get": {"tags": ["面试"], "summary": "查询投递的面试列表", "description": "学生本人或岗位所属企业可查看", "operationId": "listByApplication", "parameters": [{"name": "applicationId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListMapStringObject"}}}}}}, "post": {"tags": ["面试"], "summary": "创建面试邀请", "description": "企业为投递记录创建面试邀请", "operationId": "create_4", "parameters": [{"name": "applicationId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/InterviewCreateRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/admin/reviews/{reviewId}/decision": {"post": {"tags": ["评价审核"], "summary": "评价审核决策", "description": "仅 ADMIN;仅 PENDING 可审,重复提交 409;决策写入历史与 audit_logs", "operationId": "decide", "parameters": [{"name": "reviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReviewDecisionRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/admin/companies/verifications/{id}/decision": {"post": {"tags": ["企业认证"], "summary": "审核决定", "description": "管理员通过或拒绝认证申请,拒绝时必须填写原因", "operationId": "decide_1", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/VerificationDecisionRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/offers/{id}/status": {"patch": {"tags": ["Offer"], "summary": "Offer 状态变更", "description": "DRAFT→SENT 企业;SENT/NEGOTIATING→ACCEPTED/REJECTED/NEGOTIATING 候选人;重复提交幂等", "operationId": "changeStatus", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/OfferStatusRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/{jobId}/status": {"patch": {"tags": ["岗位"], "summary": "发布/关闭岗位", "description": "DRAFT→PUBLISHED,PUBLISHED→CLOSED;禁止非法回退", "operationId": "updateStatus", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/JobStatusRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{id}/status": {"patch": {"tags": ["投递"], "summary": "更新投递状态(企业)", "description": "SUBMITTED→VIEWED→INTERVIEW→REJECTED/HIRED", "operationId": "updateStatus_1", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApplicationStatusRequest"}}}, "required": true}, "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/review-weight-templates": {"get": {"tags": ["student-review-weight-controller"], "operationId": "getTemplates", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListWeightTemplateResponse"}}}}, "401": {"description": "未登录", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}}}}, "/resume-export-tasks/{taskId}": {"get": {"tags": ["简历版本"], "summary": "导出任务状态", "description": "仅本人可查询;SUCCESS 含输出文件,FAILED 含失败原因", "operationId": "getExportTask", "parameters": [{"name": "taskId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/resume-export-tasks/{taskId}/download": {"get": {"tags": ["简历版本"], "summary": "下载导出产物", "description": "仅本人且任务 SUCCESS 可下载;attachment;不暴露内部路径", "operationId": "downloadExportTask", "parameters": [{"name": "taskId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"type": "string", "format": "byte"}}}}}}}, "/offers/{id}/timeline": {"get": {"tags": ["Offer"], "summary": "Offer 状态时间线", "description": "完整状态变更历史,含操作者、原因与请求 ID", "operationId": "timeline", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListMapStringObject"}}}}}}}, "/notifications": {"get": {"tags": ["通知"], "summary": "通知列表", "description": "当前用户的通知,分页,按时间倒序", "operationId": "list_3", "parameters": [{"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/notifications/unread-count": {"get": {"tags": ["通知"], "summary": "未读数", "description": "当前用户未读通知数", "operationId": "unreadCount", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/{jobId}/reviews/summary": {"get": {"tags": ["岗位评价"], "summary": "评分汇总", "description": "公开访问;统计 PUBLISHED 评价的平均分、分布和热门标签", "operationId": "summary", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/{jobId}/personalized-review-score": {"get": {"tags": ["student-review-weight-controller"], "operationId": "getPersonalizedScore", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponsePersonalizedScoreResponse"}}}}, "401": {"description": "未登录", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}, "403": {"description": "非 STUDENT 角色", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}}}}, "/jobs/{jobId}/match": {"get": {"tags": ["岗位匹配"], "summary": "岗位匹配诊断", "description": "仅 STUDENT;返回匹配分、硬性条件、缺口和说明", "operationId": "diagnose", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/search": {"get": {"tags": ["岗位"], "summary": "岗位高级搜索", "description": "仅返回 PUBLISHED 岗位,支持关键词、城市、用工类型、薪资、公司筛选、排序和分页", "operationId": "searchJobs", "parameters": [{"name": "keyword", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "city", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "employmentType", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "salaryMin", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32"}}, {"name": "salaryMax", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32"}}, {"name": "companyId", "in": "query", "required": false, "schema": {"type": "integer", "format": "int64"}}, {"name": "sort", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/public/{jobId}": {"get": {"tags": ["岗位"], "summary": "查询岗位详情(公开)", "description": "仅返回 PUBLISHED 岗位详情;CLOSED 也可查看详情", "operationId": "getPublicJob", "parameters": [{"name": "jobId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/filters": {"get": {"tags": ["岗位"], "summary": "岗位筛选选项", "description": "返回当前 PUBLISHED 岗位可用的城市、用工类型和薪资范围", "operationId": "getFilters", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/favorites": {"get": {"tags": ["岗位收藏"], "summary": "我的收藏列表", "description": "仅 STUDENT;分页,按收藏时间倒序", "operationId": "listFavorites", "parameters": [{"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/jobs/compare": {"get": {"tags": ["岗位评价"], "summary": "岗位对比", "description": "公开访问;ids 为 2-3 个公开岗位;返回公开岗位字段与公开评分", "operationId": "compare", "parameters": [{"name": "ids", "in": "query", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListMapStringObject"}}}}}}}, "/interviews/{interviewId}/timeline": {"get": {"tags": ["面试"], "summary": "面试状态时间线", "description": "查看面试状态变更历史", "operationId": "timeline_1", "parameters": [{"name": "interviewId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListMapStringObject"}}}}}}}, "/interviews/me": {"get": {"tags": ["面试"], "summary": "我的面试列表", "description": "查看当前用户相关的面试,支持 status/from/to 筛选和分页", "operationId": "listMyInterviews", "parameters": [{"name": "status", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "from", "in": "query", "required": false, "schema": {"type": "string", "format": "date-time"}}, {"name": "to", "in": "query", "required": false, "schema": {"type": "string", "format": "date-time"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/info": {"get": {"tags": ["系统"], "summary": "应用信息", "description": "返回应用版本、运行时信息", "operationId": "info", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/health": {"get": {"tags": ["系统"], "summary": "应用健康检查", "description": "返回应用及依赖服务状态", "operationId": "health", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/health/details": {"get": {"tags": ["health-controller"], "operationId": "healthDetails", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/conversations/{id}": {"get": {"tags": ["消息"], "summary": "会话详情", "description": "查看会话信息和未读数", "operationId": "getConversation", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/conversations/{id}/messages": {"get": {"tags": ["消息"], "summary": "消息列表", "description": "分页查询会话消息,倒序;仅成员可查看", "operationId": "listMessages", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/company/applications": {"get": {"tags": ["投递"], "summary": "查看岗位投递列表(企业)", "description": "企业查看自己发布的某个岗位收到的投递,支持 status 筛选和分页", "operationId": "listCompanyApplications", "parameters": [{"name": "jobId", "in": "query", "required": true, "schema": {"type": "integer", "format": "int64"}}, {"name": "status", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/companies/{companyId}": {"get": {"tags": ["企业"], "summary": "查询企业公开详情", "description": "匿名可访问,仅返回 6 个公开字段", "operationId": "getPublicCompany", "parameters": [{"name": "companyId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseCompanyPublicView"}}}}}}}, "/companies/{companyId}/jobs": {"get": {"tags": ["企业"], "summary": "查询企业公开岗位列表", "description": "匿名可访问,仅返回 PUBLISHED 岗位", "operationId": "getCompanyJobs", "parameters": [{"name": "companyId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}, {"name": "sort", "in": "query", "required": false, "schema": {"type": "string", "default": "latest"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/auth/me": {"get": {"tags": ["认证"], "summary": "当前用户信息", "description": "需要 JWT 认证", "operationId": "me", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{id}": {"get": {"tags": ["投递"], "summary": "投递详情", "description": "查看自己的投递记录详情", "operationId": "getApplication", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/{id}/timeline": {"get": {"tags": ["投递"], "summary": "投递状态时间线", "description": "学生本人或岗位所属企业可查看状态变更历史", "operationId": "timeline_2", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListMapStringObject"}}}}}}}, "/applications/{id}/match": {"get": {"tags": ["双向匹配"], "summary": "查询投递匹配状态", "description": "仅投递求职者或岗位所属企业;不暴露内部路径/事件 ID", "operationId": "getMatch", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/applications/me": {"get": {"tags": ["投递"], "summary": "我的投递列表", "description": "查看当前学生自己的投递记录,支持 status 筛选和分页", "operationId": "listMyApplications", "parameters": [{"name": "status", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/admin/reviews/pending": {"get": {"tags": ["评价审核"], "summary": "待审评价列表", "description": "仅 ADMIN;分页 + jobId/riskLevel/createdFrom/createdTo 筛选;不返回明文 IP/UA", "operationId": "pending", "parameters": [{"name": "jobId", "in": "query", "required": false, "schema": {"type": "integer", "format": "int64"}}, {"name": "riskLevel", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "createdFrom", "in": "query", "required": false, "schema": {"type": "string", "format": "date-time"}}, {"name": "createdTo", "in": "query", "required": false, "schema": {"type": "string", "format": "date-time"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/admin/reviews/history": {"get": {"tags": ["评价审核"], "summary": "审核历史", "description": "仅 ADMIN;分页返回审核结果、审核人、时间与原因", "operationId": "history", "parameters": [{"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/admin/companies/verifications": {"get": {"tags": ["企业认证"], "summary": "待审核认证列表", "description": "管理员查看认证申请列表,支持 status/from/to 筛选和分页", "operationId": "listVerifications", "parameters": [{"name": "status", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "from", "in": "query", "required": false, "schema": {"type": "string", "format": "date-time"}}, {"name": "to", "in": "query", "required": false, "schema": {"type": "string", "format": "date-time"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/admin/companies/verifications/{id}": {"get": {"tags": ["企业认证"], "summary": "认证详情", "description": "管理员查看认证申请详情", "operationId": "getDetail", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/admin/companies/verifications/history": {"get": {"tags": ["企业认证"], "summary": "认证审核历史", "description": "管理员查看某个认证申请的审核历史", "operationId": "listHistory", "parameters": [{"name": "verificationId", "in": "query", "required": true, "schema": {"type": "integer", "format": "int64"}}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseListMapStringObject"}}}}}}}, "/": {"get": {"tags": ["API"], "summary": "API 信息", "description": "返回 API v0.1 基本信息", "operationId": "index", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseMapStringObject"}}}}}}}, "/profile/me/candidate-score-card": {"get": {"tags": ["求职者评分卡"], "summary": "查询求职者评分卡", "description": "仅 STUDENT;返回自己的评价聚合卡", "operationId": "getScoreCard", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseCandidateScoreCardResponse"}}}}, "401": {"description": "未登录", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}, "403": {"description": "非 STUDENT 角色", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}}}}, "/profile/me/candidate-score-trend": {"get": {"tags": ["求职者评分卡"], "summary": "查询评分趋势", "description": "仅 STUDENT;返回最近月份的反馈趋势", "operationId": "getTrend", "parameters": [{"name": "months", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 6}, "description": "统计月数,默认 6,范围 1-24"}], "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseCandidateScoreTrendResponse"}}}}, "400": {"description": "参数不合法", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}, "401": {"description": "未登录", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}, "403": {"description": "非 STUDENT 角色", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}}}}, "/profile/me/candidate-percentile": {"get": {"tags": ["求职者评分卡"], "summary": "查询百分位", "description": "仅 STUDENT;返回自己在候选人中的评分百分位", "operationId": "getPercentile", "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseCandidatePercentileResponse"}}}}, "401": {"description": "未登录", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}, "403": {"description": "非 STUDENT 角色", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}}}}}}}, "components": {"schemas": {"StudentProfileUpdateRequest": {"type": "object", "properties": {"realName": {"type": "string", "maxLength": 50, "minLength": 0}, "bio": {"type": "string"}, "location": {"type": "string", "maxLength": 100, "minLength": 0}, "education": {"type": "string", "maxLength": 100, "minLength": 0}, "school": {"type": "string", "maxLength": 150, "minLength": 0}, "graduationYear": {"type": "integer", "format": "int32"}, "resumeUrl": {"type": "string", "maxLength": 500, "minLength": 0}, "skills": {"type": "array", "items": {"type": "string"}}}}, "ApiResponseMapStringObject": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"type": "object", "additionalProperties": {}}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "SaveWeightRequest": {"type": "object", "properties": {"templateCode": {"type": "string"}, "weights": {"type": "object", "additionalProperties": {"type": "number"}}}}, "ApiResponseReviewWeightResponse": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"$ref": "#/components/schemas/ReviewWeightResponse"}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "ReviewWeightResponse": {"type": "object", "properties": {"source": {"type": "string"}, "templateCode": {"type": "string"}, "configuredWeights": {"type": "object", "additionalProperties": {"type": "number"}}, "updatedAt": {"type": "string", "format": "date-time"}}}, "StudentPreferenceRequest": {"type": "object", "properties": {"expectedSalaryMin": {"type": "integer", "format": "int32", "minimum": 0}, "expectedSalaryMax": {"type": "integer", "format": "int32", "minimum": 0}, "preferredCities": {"type": "array", "items": {"type": "string", "maxLength": 100, "minLength": 1}, "maxItems": 20, "minItems": 0}, "employmentTypes": {"type": "array", "items": {"type": "string", "maxLength": 100, "minLength": 1}, "maxItems": 20, "minItems": 0}, "preferredIndustries": {"type": "array", "items": {"type": "string", "maxLength": 100, "minLength": 1}, "maxItems": 20, "minItems": 0}, "preferredJobTypes": {"type": "array", "items": {"type": "string", "maxLength": 100, "minLength": 1}, "maxItems": 20, "minItems": 0}, "remoteAllowed": {"type": "boolean"}, "salaryRangeValid": {"type": "boolean"}}}, "StudentMatchRuleRequest": {"type": "object", "properties": {"requiredHrScore": {"type": "integer", "format": "int32", "maximum": 100, "minimum": 0}}, "required": ["requiredHrScore"]}, "OfferUpdateRequest": {"type": "object", "properties": {"title": {"type": "string", "maxLength": 120, "minLength": 0}, "salaryMin": {"type": "integer", "format": "int32", "minimum": 0}, "salaryMax": {"type": "integer", "format": "int32", "minimum": 0}, "salaryCurrency": {"type": "string", "maxLength": 10, "minLength": 0}, "salaryPeriod": {"type": "string", "pattern": "^(MONTH|YEAR|HOUR)$"}, "workCity": {"type": "string", "maxLength": 80, "minLength": 0}, "employmentType": {"type": "string", "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$"}, "probationMonths": {"type": "integer", "format": "int32", "minimum": 0}, "benefits": {"type": "string", "maxLength": 2000, "minLength": 0}, "description": {"type": "string", "maxLength": 5000, "minLength": 0}, "expireAt": {"type": "string", "format": "date-time"}}}, "JobUpdateRequest": {"type": "object", "properties": {"title": {"type": "string", "maxLength": 120, "minLength": 0}, "description": {"type": "string"}, "requirements": {"type": "string"}, "city": {"type": "string", "maxLength": 80, "minLength": 0}, "employmentType": {"type": "string", "maxLength": 30, "minLength": 0}, "salaryMin": {"type": "integer", "format": "int32"}, "salaryMax": {"type": "integer", "format": "int32"}}}, "JobReviewUpdateRequest": {"type": "object", "properties": {"rating": {"type": "integer", "format": "int32", "maximum": 5, "minimum": 1}, "content": {"type": "string", "maxLength": 1000, "minLength": 1}, "tags": {"type": "array", "items": {"type": "string", "maxLength": 50, "minLength": 1}, "maxItems": 10, "minItems": 0}, "status": {"type": "string", "pattern": "^(PUBLISHED|HIDDEN)$"}, "dimensionScores": {"type": "object", "additionalProperties": {"type": "integer", "format": "int32"}}}}, "InterviewUpdateRequest": {"type": "object", "properties": {"title": {"type": "string"}, "startAt": {"type": "string", "format": "date-time"}, "endAt": {"type": "string", "format": "date-time"}, "timezone": {"type": "string"}, "meetingUrl": {"type": "string"}, "address": {"type": "string"}, "interviewerName": {"type": "string"}, "notes": {"type": "string"}, "timeRangeValid": {"type": "boolean"}}}, "CompanyUpdateRequest": {"type": "object", "properties": {"name": {"type": "string", "maxLength": 120, "minLength": 0}, "logoUrl": {"type": "string", "maxLength": 500, "minLength": 0}, "description": {"type": "string"}, "industry": {"type": "string", "maxLength": 80, "minLength": 0}, "website": {"type": "string", "maxLength": 255, "minLength": 0}, "address": {"type": "string", "maxLength": 255, "minLength": 0}}, "required": ["name"]}, "CompanyMatchRuleRequest": {"type": "object", "properties": {"requiredCandidateScore": {"type": "integer", "format": "int32", "maximum": 100, "minimum": 0}}, "required": ["requiredCandidateScore"]}, "ScoreRequest": {"type": "object", "properties": {"score": {"type": "integer", "format": "int32", "maximum": 100, "minimum": 0}}, "required": ["score"]}, "CandidateFeedbackRequest": {"type": "object", "properties": {"rating": {"type": "integer", "format": "int32", "maximum": 5, "minimum": 1}, "content": {"type": "string", "maxLength": 1000, "minLength": 0}, "tags": {"type": "array", "items": {"type": "string", "maxLength": 50, "minLength": 1}, "maxItems": 10, "minItems": 0}}, "required": ["content", "rating"]}, "OfferCreateRequest": {"type": "object", "properties": {"applicationId": {"type": "integer", "format": "int64"}, "title": {"type": "string", "maxLength": 120, "minLength": 0}, "salaryMin": {"type": "integer", "format": "int32", "minimum": 0}, "salaryMax": {"type": "integer", "format": "int32", "minimum": 0}, "salaryCurrency": {"type": "string", "maxLength": 10, "minLength": 0}, "salaryPeriod": {"type": "string", "pattern": "^(MONTH|YEAR|HOUR)$"}, "workCity": {"type": "string", "maxLength": 80, "minLength": 0}, "employmentType": {"type": "string", "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$"}, "probationMonths": {"type": "integer", "format": "int32", "minimum": 0}, "benefits": {"type": "string", "maxLength": 2000, "minLength": 0}, "description": {"type": "string", "maxLength": 5000, "minLength": 0}, "expireAt": {"type": "string", "format": "date-time"}}, "required": ["applicationId", "description", "expireAt", "title", "workCity"]}, "JobCreateRequest": {"type": "object", "properties": {"title": {"type": "string", "maxLength": 120, "minLength": 0}, "description": {"type": "string", "minLength": 1}, "requirements": {"type": "string"}, "city": {"type": "string", "maxLength": 80, "minLength": 0}, "employmentType": {"type": "string", "maxLength": 30, "minLength": 0}, "salaryMin": {"type": "integer", "format": "int32"}, "salaryMax": {"type": "integer", "format": "int32"}}, "required": ["city", "description", "employmentType", "title"]}, "JobReviewCreateRequest": {"type": "object", "properties": {"rating": {"type": "integer", "format": "int32", "maximum": 5, "minimum": 1}, "content": {"type": "string", "maxLength": 1000, "minLength": 0}, "tags": {"type": "array", "items": {"type": "string", "maxLength": 50, "minLength": 1}, "maxItems": 10, "minItems": 0}, "dimensionScores": {"type": "object", "additionalProperties": {"type": "integer", "format": "int32"}}}, "required": ["content", "rating"]}, "InterviewRespondRequest": {"type": "object", "properties": {"action": {"type": "string", "minLength": 1}, "reason": {"type": "string"}}, "required": ["action"]}, "InterviewCancelRequest": {"type": "object", "properties": {"reason": {"type": "string"}}}, "CreateConversationRequest": {"type": "object", "properties": {"participantUserId": {"type": "integer", "format": "int64"}}, "required": ["participantUserId"]}, "SendMessageRequest": {"type": "object", "properties": {"conversationId": {"type": "integer", "format": "int64"}, "content": {"type": "string", "maxLength": 2000, "minLength": 0}}, "required": ["content", "conversationId"]}, "CompanyCreateRequest": {"type": "object", "properties": {"name": {"type": "string", "maxLength": 100, "minLength": 0}, "logoUrl": {"type": "string"}, "description": {"type": "string"}, "industry": {"type": "string"}, "website": {"type": "string"}, "address": {"type": "string"}}, "required": ["name"]}, "VerificationSubmitRequest": {"type": "object", "properties": {"materialsJson": {"type": "string", "minLength": 1}}, "required": ["materialsJson"]}, "ApiResponseMapStringString": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"type": "object", "additionalProperties": {"type": "string"}}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "LoginRequest": {"type": "object", "properties": {"phone": {"type": "string", "minLength": 1, "pattern": "[0-9]{11}"}, "code": {"type": "string", "minLength": 1, "pattern": "[0-9]{6}"}, "purpose": {"type": "string"}}, "required": ["code", "phone"]}, "ApplicationCreateRequest": {"type": "object", "properties": {"jobId": {"type": "integer", "format": "int64"}}, "required": ["jobId"]}, "InterviewCreateRequest": {"type": "object", "properties": {"roundNo": {"type": "integer", "format": "int32"}, "title": {"type": "string", "maxLength": 200, "minLength": 0}, "interviewType": {"type": "string", "minLength": 1}, "startAt": {"type": "string", "format": "date-time"}, "endAt": {"type": "string", "format": "date-time"}, "timezone": {"type": "string"}, "meetingUrl": {"type": "string"}, "address": {"type": "string"}, "interviewerName": {"type": "string", "maxLength": 100, "minLength": 0}, "notes": {"type": "string"}, "meetingUrlValid": {"type": "boolean"}, "addressValid": {"type": "boolean"}}, "required": ["endAt", "interviewType", "startAt", "title"]}, "ReviewDecisionRequest": {"type": "object", "properties": {"decision": {"type": "string", "minLength": 1, "pattern": "^(APPROVED|REJECTED)$"}, "reason": {"type": "string", "maxLength": 500, "minLength": 0}}, "required": ["decision"]}, "VerificationDecisionRequest": {"type": "object", "properties": {"decision": {"type": "string", "minLength": 1}, "reason": {"type": "string"}}, "required": ["decision"]}, "OfferStatusRequest": {"type": "object", "properties": {"status": {"type": "string", "minLength": 1, "pattern": "^(SENT|NEGOTIATING|ACCEPTED|REJECTED|EXPIRED)$"}, "reason": {"type": "string", "maxLength": 255, "minLength": 0}}, "required": ["status"]}, "JobStatusRequest": {"type": "object", "properties": {"status": {"type": "string", "minLength": 1, "pattern": "^(PUBLISHED|CLOSED)$"}}, "required": ["status"]}, "ApplicationStatusRequest": {"type": "object", "properties": {"status": {"type": "string", "minLength": 1, "pattern": "^(VIEWED|INTERVIEW|REJECTED|HIRED)$"}}, "required": ["status"]}, "ApiResponseListWeightTemplateResponse": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"type": "array", "items": {"$ref": "#/components/schemas/WeightTemplateResponse"}}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "WeightTemplateResponse": {"type": "object", "properties": {"code": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}, "weights": {"type": "object", "additionalProperties": {"type": "number"}}}}, "ApiResponseListMapStringObject": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"type": "array", "items": {"type": "object", "additionalProperties": {}}}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "ApiResponsePersonalizedScoreResponse": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"$ref": "#/components/schemas/PersonalizedScoreResponse"}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "PersonalizedScoreResponse": {"type": "object", "properties": {"jobId": {"type": "integer", "format": "int64"}, "score": {"type": "number"}, "status": {"type": "string"}, "source": {"type": "string"}, "templateCode": {"type": "string"}, "configuredWeights": {"type": "object", "additionalProperties": {"type": "number"}}, "effectiveWeights": {"type": "object", "additionalProperties": {"type": "number"}}, "dimensionScores": {"type": "object", "additionalProperties": {"type": "number"}}, "coveredWeight": {"type": "number"}, "missingDimensions": {"type": "array", "items": {"type": "string"}}}}, "ApiResponseCompanyPublicView": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"$ref": "#/components/schemas/CompanyPublicView"}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "CompanyPublicView": {"type": "object", "properties": {"id": {"type": "integer", "format": "int64"}, "name": {"type": "string"}, "logoUrl": {"type": "string"}, "description": {"type": "string"}, "industry": {"type": "string"}, "verified": {"type": "boolean"}}}, "ApiResponse": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"type": "object", "additionalProperties": {}}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "CandidateScoreCardResponse": {"type": "object", "properties": {"candidateId": {"type": "integer", "format": "int64"}, "status": {"type": "string"}, "overallScore": {"type": "number", "nullable": true}, "sampleCount": {"type": "integer", "format": "int32"}, "feedbackSummary": {"$ref": "#/components/schemas/CandidateFeedbackSummary"}, "matchSignals": {"$ref": "#/components/schemas/CandidateMatchSignals"}, "unsupportedDimensions": {"type": "array", "items": {"type": "string"}}, "generatedAt": {"type": "string", "format": "date-time"}}}, "CandidateFeedbackSummary": {"type": "object", "properties": {"totalCount": {"type": "integer", "format": "int32"}, "averageRating": {"type": "number"}, "ratingDistribution": {"type": "object", "additionalProperties": {"type": "integer"}}, "tags": {"type": "array", "items": {"$ref": "#/components/schemas/TagCount"}}}}, "TagCount": {"type": "object", "properties": {"name": {"type": "string"}, "count": {"type": "integer", "format": "int32"}}}, "CandidateMatchSignals": {"type": "object", "properties": {"candidateInterestScore": {"$ref": "#/components/schemas/MatchSignal"}, "hrSuitabilityScore": {"$ref": "#/components/schemas/MatchSignal"}}}, "MatchSignal": {"type": "object", "properties": {"average": {"type": "number", "nullable": true}, "sampleCount": {"type": "integer", "format": "int32"}}}, "CandidateScoreTrendResponse": {"type": "object", "properties": {"candidateId": {"type": "integer", "format": "int64"}, "months": {"type": "integer", "format": "int32"}, "items": {"type": "array", "items": {"$ref": "#/components/schemas/CandidateScoreTrendItem"}}}}, "CandidateScoreTrendItem": {"type": "object", "properties": {"month": {"type": "string"}, "feedbackCount": {"type": "integer", "format": "int32"}, "averageRating": {"type": "number"}, "ratingScore": {"type": "number"}}}, "CandidatePercentileResponse": {"type": "object", "properties": {"candidateId": {"type": "integer", "format": "int64"}, "status": {"type": "string"}, "scoreType": {"type": "string"}, "score": {"type": "number", "nullable": true}, "percentile": {"type": "number", "nullable": true}, "cohortSize": {"type": "integer", "format": "int32"}, "sampleCount": {"type": "integer", "format": "int32"}, "minimumSampleCount": {"type": "integer", "format": "int32"}}}, "ApiResponseCandidateScoreCardResponse": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"$ref": "#/components/schemas/CandidateScoreCardResponse"}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "ApiResponseCandidateScoreTrendResponse": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"$ref": "#/components/schemas/CandidateScoreTrendResponse"}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}, "ApiResponseCandidatePercentileResponse": {"type": "object", "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"$ref": "#/components/schemas/CandidatePercentileResponse"}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}}}}} \ No newline at end of file +{ + "openapi": "3.1.0", + "info": { + "title": "职来 API", + "version": "v1" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "tags": [ + { + "name": "岗位收藏", + "description": "学生收藏/取消收藏岗位,查看收藏列表" + }, + { + "name": "API", + "description": "API 信息入口" + }, + { + "name": "评分运营看板", + "description": "企业评分运营看板、评分排名与评分趋势" + }, + { + "name": "认证", + "description": "登录、验证码、用户信息" + }, + { + "name": "通知", + "description": "站内通知查询、已读管理" + }, + { + "name": "双向匹配沟通", + "description": "匹配成功后创建或获取直聊会话(幂等)" + }, + { + "name": "企业认证", + "description": "企业认证提交、审核管理" + }, + { + "name": "求职者评分体系", + "description": "8 维候选人生成评分、综合分、趋势与企业只读评分卡" + }, + { + "name": "投递", + "description": "学生投递、撤回、查询;企业查看和审核投递;时间线" + }, + { + "name": "Offer", + "description": "企业录用邀请:创建、发送、接受/拒绝/洽谈、到期失效、提醒与时间线" + }, + { + "name": "岗位", + "description": "岗位创建、管理、查询" + }, + { + "name": "评价审核", + "description": "评价审核与防刷:待审列表、审核决策、审核历史(仅 ADMIN)" + }, + { + "name": "简历版本", + "description": "学生简历版本管理、默认简历、导出任务" + }, + { + "name": "双向匹配阈值", + "description": "企业与求职者的匹配阈值配置" + }, + { + "name": "消息", + "description": "会话创建、消息发送、已读管理" + }, + { + "name": "评分治理", + "description": "求职者评分申诉、审核、回滚与审计" + }, + { + "name": "候选人反馈", + "description": "企业对已投递候选人的评价反馈" + }, + { + "name": "求职者评分卡", + "description": "求职者评价聚合与评分卡" + }, + { + "name": "双向匹配", + "description": "求职者意愿分、企业 HR 适配分、匹配状态" + }, + { + "name": "系统", + "description": "健康检查、应用信息" + }, + { + "name": "个人资料", + "description": "学生个人资料查询、更新与简历管理" + }, + { + "name": "岗位匹配", + "description": "学生查看岗位匹配诊断(确定性规则)" + }, + { + "name": "面试", + "description": "面试创建、查看、响应、取消、完成" + }, + { + "name": "结构化面试评分", + "description": "企业为已完成面试填写/提交结构化评分,学生查看已提交评分与历史" + }, + { + "name": "求职偏好", + "description": "学生求职偏好查询与保存" + }, + { + "name": "岗位评价", + "description": "学生对已发布岗位进行评价" + }, + { + "name": "企业", + "description": "企业资料创建、查询与更新" + } + ], + "paths": { + "/": { + "get": { + "tags": [ + "API" + ], + "summary": "API 信息", + "description": "返回 API v0.1 基本信息", + "operationId": "index", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/admin/candidate-scoring/appeals": { + "get": { + "tags": [ + "评分治理" + ], + "summary": "全部申诉列表", + "description": "仅 ADMIN", + "operationId": "listAllAppeals", + "parameters": [ + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/admin/candidate-scoring/appeals/{appealId}/decision": { + "post": { + "tags": [ + "评分治理" + ], + "summary": "审核决定", + "description": "仅 ADMIN;APPROVE/REJECT,REJECT 必须填 reason", + "operationId": "decideAppeal", + "parameters": [ + { + "name": "appealId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppealDecisionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAppealResponse" + } + } + } + } + } + } + }, + "/admin/candidate-scoring/appeals/{appealId}/history": { + "get": { + "tags": [ + "评分治理" + ], + "summary": "申诉历史", + "description": "本人(脱敏,无 actorUserId)或 ADMIN(全量);此路径的本人放行先于 ADMIN 通用规则", + "operationId": "getAppealHistory", + "parameters": [ + { + "name": "appealId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListAppealHistoryItem" + } + } + } + } + } + } + }, + "/admin/candidate-scoring/appeals/{appealId}/rollback": { + "post": { + "tags": [ + "评分治理" + ], + "summary": "执行回滚", + "description": "仅 ADMIN;记录前态+确定性重算+新版本;年 3 次上限", + "operationId": "rollbackAppeal", + "parameters": [ + { + "name": "appealId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAppealResponse" + } + } + } + } + } + } + }, + "/admin/candidate-scoring/snapshots/{candidateUserId}/{dimension}/{period}/history": { + "get": { + "tags": [ + "评分治理" + ], + "summary": "快照变更审计", + "description": "仅 ADMIN;按学生+维度+周期查看回滚前后完整变化", + "operationId": "getSnapshotHistory", + "parameters": [ + { + "name": "candidateUserId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "dimension", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "period", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListSnapshotHistoryItem" + } + } + } + } + } + } + }, + "/admin/companies/verifications": { + "get": { + "tags": [ + "企业认证" + ], + "summary": "待审核认证列表", + "description": "管理员查看认证申请列表,支持 status/from/to 筛选和分页", + "operationId": "listVerifications", + "parameters": [ + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/admin/companies/verifications/history": { + "get": { + "tags": [ + "企业认证" + ], + "summary": "认证审核历史", + "description": "管理员查看某个认证申请的审核历史", + "operationId": "listHistory", + "parameters": [ + { + "name": "verificationId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListMapStringObject" + } + } + } + } + } + } + }, + "/admin/companies/verifications/{id}": { + "get": { + "tags": [ + "企业认证" + ], + "summary": "认证详情", + "description": "管理员查看认证申请详情", + "operationId": "getDetail", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/admin/companies/verifications/{id}/decision": { + "post": { + "tags": [ + "企业认证" + ], + "summary": "审核决定", + "description": "管理员通过或拒绝认证申请,拒绝时必须填写原因", + "operationId": "decide_1", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerificationDecisionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/admin/reviews/history": { + "get": { + "tags": [ + "评价审核" + ], + "summary": "审核历史", + "description": "仅 ADMIN;分页返回审核结果、审核人、时间与原因", + "operationId": "history", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/admin/reviews/pending": { + "get": { + "tags": [ + "评价审核" + ], + "summary": "待审评价列表", + "description": "仅 ADMIN;分页 + jobId/riskLevel/createdFrom/createdTo 筛选;不返回明文 IP/UA", + "operationId": "pending", + "parameters": [ + { + "name": "jobId", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "riskLevel", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "createdFrom", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "createdTo", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/admin/reviews/{reviewId}/decision": { + "post": { + "tags": [ + "评价审核" + ], + "summary": "评价审核决策", + "description": "仅 ADMIN;仅 PENDING 可审,重复提交 409;决策写入历史与 audit_logs", + "operationId": "decide", + "parameters": [ + { + "name": "reviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewDecisionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/admin/scoring/dashboard/overview": { + "get": { + "tags": [ + "评分运营看板" + ], + "summary": "全局运营概览", + "description": "仅 ADMIN", + "operationId": "getAdminOverview", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAdminDashboardOverviewResponse" + } + } + } + } + } + } + }, + "/applications": { + "post": { + "tags": [ + "投递" + ], + "summary": "投递岗位", + "description": "只能投递 PUBLISHED 状态的岗位,不可重复投递", + "operationId": "apply", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplicationCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/applications/me": { + "get": { + "tags": [ + "投递" + ], + "summary": "我的投递列表", + "description": "查看当前学生自己的投递记录,支持 status 筛选和分页", + "operationId": "listMyApplications", + "parameters": [ + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/applications/{applicationId}/feedback": { + "get": { + "tags": [ + "候选人反馈" + ], + "summary": "查询候选人反馈", + "description": "该投递的企业或学生可查看", + "operationId": "get_4", + "parameters": [ + { + "name": "applicationId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCandidateFeedbackResponse" + } + } + } + } + } + }, + "put": { + "tags": [ + "候选人反馈" + ], + "summary": "修改候选人反馈", + "description": "仅岗位所属企业", + "operationId": "update_4", + "parameters": [ + { + "name": "applicationId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CandidateFeedbackRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCandidateFeedbackResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "候选人反馈" + ], + "summary": "创建候选人反馈", + "description": "仅岗位所属企业;同一投递只能反馈一次", + "operationId": "create", + "parameters": [ + { + "name": "applicationId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CandidateFeedbackRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCandidateFeedbackResponse" + } + } + } + } + } + }, + "delete": { + "tags": [ + "候选人反馈" + ], + "summary": "删除候选人反馈", + "description": "仅岗位所属企业", + "operationId": "delete_2", + "parameters": [ + { + "name": "applicationId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/applications/{applicationId}/interviews": { + "get": { + "tags": [ + "面试" + ], + "summary": "查询投递的面试列表", + "description": "学生本人或岗位所属企业可查看", + "operationId": "listByApplication", + "parameters": [ + { + "name": "applicationId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListMapStringObject" + } + } + } + } + } + }, + "post": { + "tags": [ + "面试" + ], + "summary": "创建面试邀请", + "description": "企业为投递记录创建面试邀请", + "operationId": "create_4", + "parameters": [ + { + "name": "applicationId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InterviewCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/applications/{id}": { + "get": { + "tags": [ + "投递" + ], + "summary": "投递详情", + "description": "查看自己的投递记录详情", + "operationId": "getApplication", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/applications/{id}/candidate-interest-score": { + "put": { + "tags": [ + "双向匹配" + ], + "summary": "提交求职者岗位意愿分", + "description": "仅投递本人(STUDENT);0-100;MATCHED/CLOSED 后不可修改", + "operationId": "submitCandidateInterestScore", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScoreRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/applications/{id}/conversation": { + "post": { + "tags": [ + "双向匹配沟通" + ], + "summary": "创建/获取匹配会话", + "description": "仅 MATCHED 双方可调用;重复调用返回同一会话", + "operationId": "createOrGetConversation", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/applications/{id}/hr-suitability-score": { + "put": { + "tags": [ + "双向匹配" + ], + "summary": "提交企业 HR 适配分", + "description": "仅岗位所属企业;0-100;MATCHED/CLOSED 后不可修改", + "operationId": "submitHrSuitabilityScore", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScoreRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/applications/{id}/match": { + "get": { + "tags": [ + "双向匹配" + ], + "summary": "查询投递匹配状态", + "description": "仅投递求职者或岗位所属企业;不暴露内部路径/事件 ID", + "operationId": "getMatch", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/applications/{id}/status": { + "patch": { + "tags": [ + "投递" + ], + "summary": "更新投递状态(企业)", + "description": "SUBMITTED→VIEWED→INTERVIEW→REJECTED/HIRED", + "operationId": "updateStatus_1", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplicationStatusRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/applications/{id}/timeline": { + "get": { + "tags": [ + "投递" + ], + "summary": "投递状态时间线", + "description": "学生本人或岗位所属企业可查看状态变更历史", + "operationId": "timeline_2", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListMapStringObject" + } + } + } + } + } + } + }, + "/applications/{id}/withdraw": { + "post": { + "tags": [ + "投递" + ], + "summary": "撤回投递", + "description": "仅 SUBMITTED/VIEWED 状态可撤回,撤回后不可逆", + "operationId": "withdraw", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/auth/login": { + "post": { + "tags": [ + "认证" + ], + "summary": "Mock 登录", + "description": "使用手机号+验证码登录,返回 JWT token", + "operationId": "mockLogin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/auth/me": { + "get": { + "tags": [ + "认证" + ], + "summary": "当前用户信息", + "description": "需要 JWT 认证", + "operationId": "me", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/auth/sms/send": { + "post": { + "tags": [ + "认证" + ], + "summary": "发送短信验证码(Mock)", + "description": "开发环境 Mock:验证码会打印到日志", + "operationId": "sendSmsCode", + "parameters": [ + { + "name": "phone", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "pattern": "[0-9]{11}" + } + }, + { + "name": "purpose", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "login" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringString" + } + } + } + } + } + } + }, + "/companies/me": { + "get": { + "tags": [ + "企业" + ], + "summary": "查询当前企业资料", + "description": "返回当前登录用户绑定的企业资料,未绑定企业返回 404", + "operationId": "getMyCompany", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "put": { + "tags": [ + "企业" + ], + "summary": "更新企业资料", + "description": "仅更新传入的非 null 字段;未绑定企业时返回 404", + "operationId": "updateMyCompany", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanyUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "post": { + "tags": [ + "企业" + ], + "summary": "创建企业资料", + "description": "仅 COMPANY 角色可调用;已存在时返回 409", + "operationId": "createMyCompany", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanyCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/companies/me/match-rule": { + "get": { + "tags": [ + "双向匹配阈值" + ], + "summary": "查询企业匹配阈值", + "description": "未配置返回默认 60", + "operationId": "getCompanyRule", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "put": { + "tags": [ + "双向匹配阈值" + ], + "summary": "更新企业匹配阈值", + "description": "requiredCandidateScore 0-100;首次更新创建配置", + "operationId": "updateCompanyRule", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanyMatchRuleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/companies/me/verification": { + "get": { + "tags": [ + "企业认证" + ], + "summary": "查询企业认证状态", + "description": "企业查询自己的认证状态和历史", + "operationId": "getMyVerification", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "post": { + "tags": [ + "企业认证" + ], + "summary": "提交企业认证", + "description": "企业提交认证材料(营业执照等),只能提交自己的认证", + "operationId": "submit", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerificationSubmitRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/companies/{companyId}": { + "get": { + "tags": [ + "企业" + ], + "summary": "查询企业公开详情", + "description": "匿名可访问,仅返回 6 个公开字段", + "operationId": "getPublicCompany", + "parameters": [ + { + "name": "companyId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCompanyPublicView" + } + } + } + } + } + } + }, + "/companies/{companyId}/jobs": { + "get": { + "tags": [ + "企业" + ], + "summary": "查询企业公开岗位列表", + "description": "匿名可访问,仅返回 PUBLISHED 岗位", + "operationId": "getCompanyJobs", + "parameters": [ + { + "name": "companyId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "latest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/company/applications": { + "get": { + "tags": [ + "投递" + ], + "summary": "查看岗位投递列表(企业)", + "description": "企业查看自己发布的某个岗位收到的投递,支持 status 筛选和分页", + "operationId": "listCompanyApplications", + "parameters": [ + { + "name": "jobId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/company/candidates": { + "get": { + "tags": [ + "投递" + ], + "summary": "企业候选人列表", + "description": "跨岗位查询本企业所有候选人投递,支持按岗位、状态、关键词筛选,支持分页和排序", + "operationId": "listCompanyCandidates", + "parameters": [ + { + "name": "jobId", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "keyword", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "order", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCandidateListResponse" + } + } + } + } + } + } + }, + "/company/candidates/{applicationId}": { + "get": { + "tags": [ + "投递" + ], + "summary": "企业候选人详情", + "description": "查看投递详情及候选人公开资料,只能查看本企业岗位下的投递", + "operationId": "getCompanyCandidateDetail", + "parameters": [ + { + "name": "applicationId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCandidateDetailView" + } + } + } + } + } + } + }, + "/company/candidates/{applicationId}/scoring": { + "get": { + "tags": [ + "求职者评分体系" + ], + "summary": "企业候选人评分卡", + "description": "仅岗位所属企业;只读,仅返回 applicationId/overallScore/status/dimensions/generatedAt/rulesVersion,不泄露候选人敏感信息", + "operationId": "getCompanyScoring", + "parameters": [ + { + "name": "applicationId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCompanyCandidateScoringResponse" + } + } + } + } + } + } + }, + "/company/scoring/dashboard/candidate-rankings": { + "get": { + "tags": [ + "评分运营看板" + ], + "summary": "候选人评分排名", + "description": "仅 COMPANY;按学生去重;overallScore DESC, applicationId DESC", + "operationId": "getCandidateRankings", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCandidateRankingListResponse" + } + } + } + } + } + } + }, + "/company/scoring/dashboard/candidate-rankings/{applicationId}": { + "get": { + "tags": [ + "评分运营看板" + ], + "summary": "单候选人评分排名明细", + "description": "仅 COMPANY;跨企业 403/8004", + "operationId": "getCandidateRanking", + "parameters": [ + { + "name": "applicationId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCandidateRankingDetailResponse" + } + } + } + } + } + } + }, + "/company/scoring/dashboard/job-rankings": { + "get": { + "tags": [ + "评分运营看板" + ], + "summary": "岗位评分排名", + "description": "仅 COMPANY;avgRating DESC, jobId DESC;审核通过评价", + "operationId": "getJobRankings", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseJobRankingListResponse" + } + } + } + } + } + } + }, + "/company/scoring/dashboard/job-rankings/{jobId}": { + "get": { + "tags": [ + "评分运营看板" + ], + "summary": "单岗位评分排名", + "description": "仅 COMPANY;跨企业 403/8004", + "operationId": "getJobRanking", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseJobRankingItem" + } + } + } + } + } + } + }, + "/company/scoring/dashboard/overview": { + "get": { + "tags": [ + "评分运营看板" + ], + "summary": "企业评分运营概览", + "description": "仅 COMPANY;只返回本企业数据", + "operationId": "getOverview_1", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseDashboardOverviewResponse" + } + } + } + } + } + } + }, + "/company/scoring/dashboard/trend": { + "get": { + "tags": [ + "评分运营看板" + ], + "summary": "企业评分趋势", + "description": "仅 COMPANY;months 1–24", + "operationId": "getTrend_2", + "parameters": [ + { + "name": "months", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 6 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseScoreTrendResponse" + } + } + } + } + } + } + }, + "/conversations": { + "get": { + "tags": [ + "消息" + ], + "summary": "会话列表", + "description": "当前用户参与的所有会话", + "operationId": "listConversations", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListMapStringObject" + } + } + } + } + } + }, + "post": { + "tags": [ + "消息" + ], + "summary": "创建/获取会话", + "description": "创建或返回与另一用户的 DIRECT 会话", + "operationId": "createConversation", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateConversationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/conversations/messages": { + "post": { + "tags": [ + "消息" + ], + "summary": "发送消息", + "description": "在会话中发送消息,仅成员可发送", + "operationId": "sendMessage", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendMessageRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/conversations/{id}": { + "get": { + "tags": [ + "消息" + ], + "summary": "会话详情", + "description": "查看会话信息和未读数", + "operationId": "getConversation", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/conversations/{id}/messages": { + "get": { + "tags": [ + "消息" + ], + "summary": "消息列表", + "description": "分页查询会话消息,倒序;仅成员可查看", + "operationId": "listMessages", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/conversations/{id}/read": { + "post": { + "tags": [ + "消息" + ], + "summary": "标记已读", + "description": "将当前会话所有消息标记为已读", + "operationId": "markRead_1", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/health": { + "get": { + "tags": [ + "系统" + ], + "summary": "应用健康检查", + "description": "返回应用及依赖服务状态", + "operationId": "health", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/health/details": { + "get": { + "tags": [ + "health-controller" + ], + "operationId": "healthDetails", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/info": { + "get": { + "tags": [ + "系统" + ], + "summary": "应用信息", + "description": "返回应用版本、运行时信息", + "operationId": "info", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/interviews/me": { + "get": { + "tags": [ + "面试" + ], + "summary": "我的面试列表", + "description": "查看当前用户相关的面试,支持 status/from/to 筛选和分页", + "operationId": "listMyInterviews", + "parameters": [ + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/interviews/{interviewId}": { + "get": { + "tags": [ + "面试" + ], + "summary": "面试详情", + "description": "学生本人或企业可查看面试详情", + "operationId": "get_3", + "parameters": [ + { + "name": "interviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "put": { + "tags": [ + "面试" + ], + "summary": "修改面试", + "description": "企业修改面试时间、地点等信息,仅 SCHEDULED/CONFIRMED 状态可修改", + "operationId": "update_3", + "parameters": [ + { + "name": "interviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InterviewUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/interviews/{interviewId}/cancel": { + "post": { + "tags": [ + "面试" + ], + "summary": "取消面试", + "description": "企业取消已安排的面试", + "operationId": "cancel", + "parameters": [ + { + "name": "interviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InterviewCancelRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/interviews/{interviewId}/complete": { + "post": { + "tags": [ + "面试" + ], + "summary": "完成面试", + "description": "企业在面试时间结束后将面试标记为完成", + "operationId": "complete", + "parameters": [ + { + "name": "interviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/interviews/{interviewId}/respond": { + "post": { + "tags": [ + "面试" + ], + "summary": "响应面试邀请", + "description": "学生接受、拒绝或申请改期", + "operationId": "respond", + "parameters": [ + { + "name": "interviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InterviewRespondRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/interviews/{interviewId}/timeline": { + "get": { + "tags": [ + "面试" + ], + "summary": "面试状态时间线", + "description": "查看面试状态变更历史", + "operationId": "timeline_1", + "parameters": [ + { + "name": "interviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListMapStringObject" + } + } + } + } + } + } + }, + "/jobs": { + "get": { + "tags": [ + "岗位" + ], + "summary": "查询已发布岗位列表", + "description": "仅返回 PUBLISHED 岗位,支持关键词、城市、用工类型、薪资筛选和分页", + "operationId": "listJobs", + "parameters": [ + { + "name": "keyword", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "city", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "employmentType", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "salaryMin", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "salaryMax", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "post": { + "tags": [ + "岗位" + ], + "summary": "创建岗位", + "description": "仅企业账号可调用,新岗位默认 DRAFT 状态", + "operationId": "createJob", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/jobs/compare": { + "get": { + "tags": [ + "岗位评价" + ], + "summary": "岗位对比", + "description": "公开访问;ids 为 2-3 个公开岗位;返回公开岗位字段与公开评分", + "operationId": "compare_1", + "parameters": [ + { + "name": "ids", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListMapStringObject" + } + } + } + } + } + } + }, + "/jobs/favorites": { + "get": { + "tags": [ + "岗位收藏" + ], + "summary": "我的收藏列表", + "description": "仅 STUDENT;分页,按收藏时间倒序", + "operationId": "listFavorites", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/jobs/filters": { + "get": { + "tags": [ + "岗位" + ], + "summary": "岗位筛选选项", + "description": "返回当前 PUBLISHED 岗位可用的城市、用工类型和薪资范围", + "operationId": "getFilters", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/jobs/public/{jobId}": { + "get": { + "tags": [ + "岗位" + ], + "summary": "查询岗位详情(公开)", + "description": "仅返回 PUBLISHED 岗位详情;CLOSED 也可查看详情", + "operationId": "getPublicJob", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/jobs/search": { + "get": { + "tags": [ + "岗位" + ], + "summary": "岗位高级搜索", + "description": "仅返回 PUBLISHED 岗位,支持关键词、城市、用工类型、薪资、公司筛选、排序和分页", + "operationId": "searchJobs", + "parameters": [ + { + "name": "keyword", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "city", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "employmentType", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "salaryMin", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "salaryMax", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "companyId", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/jobs/{jobId}": { + "get": { + "tags": [ + "岗位" + ], + "summary": "查询岗位详情", + "description": "企业可查看自己管理的岗位详情", + "operationId": "getJob", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "put": { + "tags": [ + "岗位" + ], + "summary": "更新岗位", + "description": "仅岗位所属企业可修改", + "operationId": "updateJob", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/jobs/{jobId}/favorite": { + "post": { + "tags": [ + "岗位收藏" + ], + "summary": "收藏岗位", + "description": "仅 STUDENT;仅 PUBLISHED;幂等", + "operationId": "favorite", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "delete": { + "tags": [ + "岗位收藏" + ], + "summary": "取消收藏", + "description": "仅 STUDENT;幂等,未收藏也返回 200", + "operationId": "unfavorite", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/jobs/{jobId}/match": { + "get": { + "tags": [ + "岗位匹配" + ], + "summary": "岗位匹配诊断", + "description": "仅 STUDENT;返回匹配分、硬性条件、缺口和说明", + "operationId": "diagnose", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/jobs/{jobId}/personalized-review-score": { + "get": { + "tags": [ + "student-review-weight-controller" + ], + "operationId": "getPersonalizedScore", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponsePersonalizedScoreResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非 STUDENT 角色", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + } + }, + "/jobs/{jobId}/competition-ranking": { + "get": { + "tags": [ + "同岗位竞争力排名" + ], + "summary": "同岗位竞争力排名", + "description": "仅 STUDENT 且已投递该(PUBLISHED)岗位的学生可查看。返回本人排名/综合分/百分位/维度/缺口及匿名 Top 5。COMPANY/ADMIN 或未投递学生返回 403;岗位不存在或非 PUBLISHED 返回 404。身份仅取自 JWT,不接收 userId/studentUserId/candidateId 参数。topCandidates 每项仅含 rank 与 overallScore,不含任何身份字段(studentUserId/candidateId/name/school/phone/email/resumeUrl/applicationId)。cohort 排除 WITHDRAWN/REJECTED 投递。", + "operationId": "getCompetitionRanking", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "400": { + "description": "jobId 为空或 <=0", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非 STUDENT 角色或学生未投递该岗位", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "404": { + "description": "岗位不存在或岗位状态非 PUBLISHED", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/jobs/{jobId}/reviews": { + "get": { + "tags": [ + "岗位评价" + ], + "summary": "查询岗位评价列表", + "description": "公开访问;仅返回 PUBLISHED 评价,分页;每条含八维评分", + "operationId": "list_2", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "post": { + "tags": [ + "岗位评价" + ], + "summary": "创建岗位评价", + "description": "仅 STUDENT;同一学生对同一岗位只能评价一次", + "operationId": "create_3", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobReviewCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/jobs/{jobId}/reviews/summary": { + "get": { + "tags": [ + "岗位评价" + ], + "summary": "评分汇总", + "description": "公开访问;统计 PUBLISHED 评价的平均分、分布和热门标签", + "operationId": "summary", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/jobs/{jobId}/reviews/{reviewId}": { + "get": { + "tags": [ + "岗位评价" + ], + "summary": "岗位评价详情", + "description": "公开访问;返回单条 PUBLISHED 评价与八维评分", + "operationId": "get_2", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "reviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "put": { + "tags": [ + "岗位评价" + ], + "summary": "修改评价", + "description": "仅评价本人可以修改", + "operationId": "update_2", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "reviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobReviewUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "delete": { + "tags": [ + "岗位评价" + ], + "summary": "删除评价", + "description": "仅评价本人可以删除", + "operationId": "delete_1", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "reviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/jobs/{jobId}/status": { + "patch": { + "tags": [ + "岗位" + ], + "summary": "发布/关闭岗位", + "description": "DRAFT→PUBLISHED,PUBLISHED→CLOSED;禁止非法回退", + "operationId": "updateStatus", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobStatusRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/notifications": { + "get": { + "tags": [ + "通知" + ], + "summary": "通知列表", + "description": "当前用户的通知,分页,按时间倒序", + "operationId": "list_3", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/notifications/read-all": { + "post": { + "tags": [ + "通知" + ], + "summary": "全部已读", + "description": "标记当前用户全部通知已读(幂等)", + "operationId": "markAllRead", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/notifications/unread-count": { + "get": { + "tags": [ + "通知" + ], + "summary": "未读数", + "description": "当前用户未读通知数", + "operationId": "unreadCount", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/notifications/{id}/read": { + "post": { + "tags": [ + "通知" + ], + "summary": "标记已读", + "description": "标记单条通知已读(幂等)", + "operationId": "markRead", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/offers": { + "get": { + "tags": [ + "Offer" + ], + "summary": "Offer 列表", + "description": "候选人查看收到的、企业查看发出的 Offer;支持 status、applicationId 筛选与分页", + "operationId": "list_1", + "parameters": [ + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "applicationId", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "post": { + "tags": [ + "Offer" + ], + "summary": "创建 Offer", + "description": "仅企业账号;投递存在、岗位属于本企业、投递状态可发、无有效 Offer;默认 DRAFT", + "operationId": "create_2", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OfferCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/offers/compare": { + "get": { + "tags": [ + "Offer" + ], + "summary": "Offer 比较", + "description": "仅候选人本人;比较 2-5 个非 DRAFT 的 Offer", + "operationId": "compare", + "parameters": [ + { + "name": "ids", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListMapStringObject" + } + } + } + } + } + } + }, + "/offers/statistics": { + "get": { + "tags": [ + "Offer" + ], + "summary": "Offer 统计", + "description": "仅企业;统计本企业 Offer 状态分布、接受率、平均响应时间", + "operationId": "statistics", + "parameters": [ + { + "name": "months", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 6 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/offers/{id}": { + "get": { + "tags": [ + "Offer" + ], + "summary": "Offer 详情", + "description": "仅目标候选人、所属企业可访问;其他账号返回无权限错误", + "operationId": "get_1", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "put": { + "tags": [ + "Offer" + ], + "summary": "修改 Offer", + "description": "仅所属企业、仅 DRAFT 状态可修改;其余状态返回业务错误", + "operationId": "update_1", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OfferUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/offers/{id}/counter-offers": { + "get": { + "tags": [ + "Offer" + ], + "summary": "还价列表", + "description": "候选人查看本人 Offer 的还价、企业查看本企业 Offer 的还价", + "operationId": "listCounterOffers", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListMapStringObject" + } + } + } + } + } + }, + "post": { + "tags": [ + "Offer" + ], + "summary": "发起还价", + "description": "仅候选人本人;仅 SENT/NEGOTIATING 可发起;SENT 时自动进入 NEGOTIATING", + "operationId": "createCounterOffer", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CounterOfferCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/offers/{id}/counter-offers/{coId}/accept": { + "patch": { + "tags": [ + "Offer" + ], + "summary": "接受还价", + "description": "仅所属企业;接受后更新 Offer 薪资/入职日期并变为 ACCEPTED", + "operationId": "acceptCounterOffer", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "coId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/offers/{id}/counter-offers/{coId}/reject": { + "patch": { + "tags": [ + "Offer" + ], + "summary": "拒绝还价", + "description": "仅所属企业;拒绝后 Offer 保持 NEGOTIATING", + "operationId": "rejectCounterOffer", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "coId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/offers/{id}/remind": { + "post": { + "tags": [ + "Offer" + ], + "summary": "手动提醒", + "description": "仅所属企业;仅 SENT/NEGOTIATING 可提醒;相同提醒不重复创建", + "operationId": "remind", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/offers/{id}/status": { + "patch": { + "tags": [ + "Offer" + ], + "summary": "Offer 状态变更", + "description": "DRAFT→SENT 企业;SENT/NEGOTIATING→ACCEPTED/REJECTED/NEGOTIATING 候选人;重复提交幂等", + "operationId": "changeStatus", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OfferStatusRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/offers/{id}/timeline": { + "get": { + "tags": [ + "Offer" + ], + "summary": "Offer 状态时间线", + "description": "完整状态变更历史,含操作者、原因与请求 ID", + "operationId": "timeline", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListMapStringObject" + } + } + } + } + } + } + }, + "/profile/me": { + "get": { + "tags": [ + "个人资料" + ], + "summary": "查询当前学生资料", + "description": "查询当前登录学生(仅 STUDENT 角色)自己的学生资料。身份仅从 JWT/SecurityContext 获取,不接受 userId、studentUserId 等请求参数;本接口无 Path、Query、Body 参数。若尚未创建个人资料:HTTP 200,data 为空对象 {}(非 null),且不会自动创建数据库记录。当前未设置接口级限流。", + "operationId": "getMyProfile", + "responses": { + "200": { + "description": "成功;有资料返回资料对象,无资料返回空对象 {};message=ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseStudentProfileResponse" + }, + "examples": { + "success-with-data": { + "summary": "有资料", + "description": "success-with-data", + "value": { + "code": 200, + "message": "ok", + "data": { + "id": 1, + "userId": 2, + "realName": "张三", + "education": "本科", + "school": "某某大学", + "graduationYear": 2026, + "resumeUrl": "/uploads/resumes/2/resume.pdf", + "skills": "[Java, Spring Boot]", + "createdAt": "2026-08-24T10:00:00" + }, + "timestamp": "2026-08-24T10:00:00Z" + } + } + } + } + } + }, + "401": { + "description": "未登录、Token 缺失/非法/过期;运行时实际返回 code=401、message=not authenticated", + "content": { + "application/json": { + "examples": { + "unauthorized": { + "description": "unauthorized", + "value": { + "code": 401, + "message": "not authenticated", + "timestamp": "2026-08-24T10:00:00Z" + } + } + } + } + } + }, + "403": { + "description": "已登录但不是 STUDENT 角色(COMPANY/ADMIN);code=2002、message=forbidden", + "content": { + "application/json": { + "examples": { + "forbidden": { + "description": "forbidden", + "value": { + "code": 2002, + "message": "forbidden", + "data": null, + "timestamp": "2026-08-24T10:00:00Z" + } + } + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "个人资料" + ], + "summary": "更新学生资料", + "description": "仅更新传入的非 null 字段;不存在则自动创建", + "operationId": "updateMyProfile", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StudentProfileUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/profile/me/candidate-percentile": { + "get": { + "tags": [ + "求职者评分卡" + ], + "summary": "查询百分位", + "description": "仅 STUDENT;返回自己在候选人中的评分百分位", + "operationId": "getPercentile", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCandidatePercentileResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非 STUDENT 角色", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + } + }, + "/profile/me/candidate-score-card": { + "get": { + "tags": [ + "求职者评分卡" + ], + "summary": "查询求职者评分卡", + "description": "仅 STUDENT;返回自己的评价聚合卡", + "operationId": "getScoreCard", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCandidateScoreCardResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非 STUDENT 角色", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + } + }, + "/profile/me/candidate-score-trend": { + "get": { + "tags": [ + "求职者评分卡" + ], + "summary": "查询评分趋势", + "description": "仅 STUDENT;返回最近月份的反馈趋势", + "operationId": "getTrend_1", + "parameters": [ + { + "name": "months", + "in": "query", + "description": "统计月数,默认 6,范围 1-24", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 6 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCandidateScoreTrendResponse" + } + } + } + }, + "400": { + "description": "参数不合法", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCandidateScoreTrendResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非 STUDENT 角色", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + } + }, + "/profile/me/candidate-scoring": { + "get": { + "tags": [ + "求职者评分体系" + ], + "summary": "综合评分卡", + "description": "仅 STUDENT;返回本人 8 维评分、综合分、权重与状态", + "operationId": "getOverview", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCandidateScoringOverviewResponse" + } + } + } + } + } + } + }, + "/profile/me/candidate-scoring/appeals": { + "get": { + "tags": [ + "评分治理" + ], + "summary": "我的申诉列表", + "description": "仅 STUDENT 本人", + "operationId": "listMyAppeals", + "parameters": [ + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "post": { + "tags": [ + "评分治理" + ], + "summary": "提交评分申诉", + "description": "仅 STUDENT 本人;同一学生+维度+周期 PENDING/APPROVED 唯一", + "operationId": "createAppeal", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAppealRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAppealResponse" + } + } + } + } + } + } + }, + "/profile/me/candidate-scoring/appeals/{appealId}": { + "get": { + "tags": [ + "评分治理" + ], + "summary": "申诉详情", + "description": "本人或 ADMIN", + "operationId": "getAppeal", + "parameters": [ + { + "name": "appealId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAppealResponse" + } + } + } + } + } + } + }, + "/profile/me/candidate-scoring/appeals/{appealId}/cancel": { + "post": { + "tags": [ + "评分治理" + ], + "summary": "撤销申诉", + "description": "仅本人;仅 PENDING 可撤销", + "operationId": "cancelAppeal", + "parameters": [ + { + "name": "appealId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAppealResponse" + } + } + } + } + } + } + }, + "/profile/me/candidate-scoring/dimensions/{dimension}": { + "get": { + "tags": [ + "求职者评分体系" + ], + "summary": "单维度明细", + "description": "仅 STUDENT;返回某维度分项、归因与数据源;非法 dimension 返回 400", + "operationId": "getDimensionDetail", + "parameters": [ + { + "name": "dimension", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseDimensionScoreDetailResponse" + } + } + } + } + } + } + }, + "/profile/me/candidate-scoring/trend": { + "get": { + "tags": [ + "求职者评分体系" + ], + "summary": "评分趋势", + "description": "仅 STUDENT;基于快照按月返回综合分与维度分(months 1-24)", + "operationId": "getTrend", + "parameters": [ + { + "name": "months", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 6 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCandidateScoringTrendResponse" + } + } + } + } + } + } + }, + "/profile/me/deletion": { + "get": { + "tags": [ + "account-deletion-controller" + ], + "operationId": "getStatus", + "parameters": [], + "responses": { + "200": { + "description": "success", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAccountDeletionResponse" + } + } + } + }, + "401": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "account-deletion-controller" + ], + "operationId": "initiate", + "parameters": [], + "responses": { + "200": { + "description": "success", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAccountDeletionResponse" + } + } + } + }, + "401": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "409": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + } + }, + "/profile/me/deletion/cancel": { + "post": { + "tags": [ + "account-deletion-controller" + ], + "operationId": "cancel", + "parameters": [], + "responses": { + "200": { + "description": "success", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseAccountDeletionResponse" + } + } + } + }, + "401": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "409": { + "description": "error", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + } + }, + "/profile/me/data-export-tasks": { + "get": { + "tags": [ + "personal-data-export-controller" + ], + "summary": "查询个人数据导出任务列表", + "description": "仅本人;按创建时间倒序分页;status 可选:PENDING/PROCESSING/COMPLETED/FAILED/EXPIRED/CANCELLED", + "operationId": "listDataExportTasks", + "parameters": [ + { "name": "page", "in": "query", "required": false, "schema": { "type": "integer", "default": 0 } }, + { "name": "size", "in": "query", "required": false, "schema": { "type": "integer", "default": 20 } }, + { "name": "status", "in": "query", "required": false, "schema": { "type": "string" } } + ], + "responses": { + "200": { + "description": "success", + "content": { + "*/*": { + "schema": { "$ref": "#/components/schemas/ApiResponsePageResponseDataExportTaskResponse" } + } + } + }, + "401": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "403": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "409": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + } + }, + "post": { + "tags": [ + "personal-data-export-controller" + ], + "summary": "创建个人数据导出任务", + "description": "异步生成 ZIP;同 idempotencyKey 重放返回原任务;注销 PENDING/COMPLETED 禁止创建", + "operationId": "createDataExportTask", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateDataExportRequest" } + } + }, + "required": false + }, + "responses": { + "200": { + "description": "success", + "content": { + "*/*": { + "schema": { "$ref": "#/components/schemas/ApiResponseDataExportTaskResponse" } + } + } + }, + "401": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "403": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "409": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + } + } + }, + "/profile/me/data-export-tasks/{taskId}": { + "get": { + "tags": [ + "personal-data-export-controller" + ], + "summary": "查询个人数据导出任务详情", + "description": "仅本人;不暴露 storageKey 与内部字段", + "operationId": "getDataExportTask", + "parameters": [ + { "name": "taskId", "in": "path", "required": true, "schema": { "type": "integer", "format": "int64" } } + ], + "responses": { + "200": { + "description": "success", + "content": { + "*/*": { + "schema": { "$ref": "#/components/schemas/ApiResponseDataExportTaskResponse" } + } + } + }, + "401": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "403": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "404": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + } + } + }, + "/profile/me/data-export-tasks/{taskId}/download": { + "get": { + "tags": [ + "personal-data-export-controller" + ], + "summary": "下载个人数据导出 ZIP", + "description": "仅本人且 COMPLETED 未过期;attachment;需 JWT", + "operationId": "downloadDataExportTask", + "parameters": [ + { "name": "taskId", "in": "path", "required": true, "schema": { "type": "integer", "format": "int64" } } + ], + "responses": { + "200": { + "description": "success", + "content": { + "application/zip": { + "schema": { "type": "string", "format": "binary" } + } + } + }, + "401": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "403": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "409": { "description": "error", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + } + } + }, + "/profile/me/match-rule": { + "get": { + "tags": [ + "双向匹配阈值" + ], + "summary": "查询学生匹配阈值", + "description": "未配置返回默认 60", + "operationId": "getStudentRule", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "put": { + "tags": [ + "双向匹配阈值" + ], + "summary": "更新学生匹配阈值", + "description": "requiredHrScore 0-100;首次更新创建配置", + "operationId": "updateStudentRule", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StudentMatchRuleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/profile/me/preferences": { + "get": { + "tags": [ + "求职偏好" + ], + "summary": "查询求职偏好", + "description": "仅 STUDENT;无记录时返回空偏好对象", + "operationId": "getPreferences", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "put": { + "tags": [ + "求职偏好" + ], + "summary": "保存求职偏好", + "description": "仅 STUDENT;upsert,完全替换传入字段", + "operationId": "savePreferences", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StudentPreferenceRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/profile/me/privacy": { + "get": { + "tags": [ + "privacy-settings-controller" + ], + "operationId": "getMyPrivacySettings", + "parameters": [], + "responses": { + "200": { + "description": "成功;无记录时返回默认对象(PRIVATE/contactVisible=false/version=0)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponsePrivacySettingsResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非 STUDENT 角色", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + }, + "put": { + "tags": [ + "privacy-settings-controller" + ], + "operationId": "updateMyPrivacySettings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrivacySettingsUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "成功;返回更新后的隐私设置对象", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponsePrivacySettingsResponse" + } + } + } + }, + "400": { + "description": "请求体校验失败(缺少必填字段或非法枚举)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非 STUDENT 角色", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "409": { + "description": "乐观锁版本冲突(9201)", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + } + }, + "/profile/me/resume": { + "get": { + "tags": [ + "个人资料" + ], + "summary": "下载简历", + "description": "下载当前学生上传的简历文件,仅 STUDENT 角色", + "operationId": "downloadResume", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "byte" + } + } + } + } + } + }, + "post": { + "tags": [ + "个人资料" + ], + "summary": "上传简历", + "description": "仅 STUDENT 角色;PDF/DOC/DOCX,≤10MB;上传新简历时替换旧文件", + "operationId": "uploadResume", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "delete": { + "tags": [ + "个人资料" + ], + "summary": "删除简历", + "description": "删除当前学生的简历文件并清空 resumeUrl,仅 STUDENT 角色", + "operationId": "deleteResume", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/profile/me/resume-versions": { + "get": { + "tags": [ + "简历版本" + ], + "summary": "简历版本列表", + "description": "本人 ACTIVE 版本,version_no 倒序", + "operationId": "list", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListMapStringObject" + } + } + } + } + } + }, + "post": { + "tags": [ + "简历版本" + ], + "summary": "创建简历版本", + "description": "仅本人(STUDENT);PDF/DOC/DOCX ≤10MB;可附版本名", + "operationId": "create_1", + "parameters": [ + { + "name": "name", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/profile/me/resume-versions/{id}": { + "get": { + "tags": [ + "简历版本" + ], + "summary": "简历版本详情", + "description": "仅本人可查看;他人 403,不存在 404", + "operationId": "get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "put": { + "tags": [ + "简历版本" + ], + "summary": "修改简历版本", + "description": "本人;可选重命名 + 可选替换文件(替换时删除旧文件)", + "operationId": "update", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "name", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + }, + "delete": { + "tags": [ + "简历版本" + ], + "summary": "删除简历版本", + "description": "默认版本需先设其他默认;导出进行中不可删;删除文件并移除记录", + "operationId": "delete", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/profile/me/resume-versions/{id}/default": { + "post": { + "tags": [ + "简历版本" + ], + "summary": "设为默认简历", + "description": "同用户最多一个默认;已默认幂等;并发冲突 409", + "operationId": "setDefault", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/profile/me/resume-versions/{id}/export": { + "post": { + "tags": [ + "简历版本" + ], + "summary": "创建简历导出任务", + "description": "异步复制该版本文件到导出目录;返回任务状态", + "operationId": "createExportTask", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/profile/me/review-weights": { + "get": { + "tags": [ + "student-review-weight-controller" + ], + "operationId": "getMyWeights", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseReviewWeightResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非 STUDENT 角色", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + }, + "put": { + "tags": [ + "student-review-weight-controller" + ], + "operationId": "saveWeights", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveWeightRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseReviewWeightResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非 STUDENT 角色", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + } + }, + "/resume-export-tasks/{taskId}": { + "get": { + "tags": [ + "简历版本" + ], + "summary": "导出任务状态", + "description": "仅本人可查询;SUCCESS 含输出文件,FAILED 含失败原因", + "operationId": "getExportTask", + "parameters": [ + { + "name": "taskId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/resume-export-tasks/{taskId}/download": { + "get": { + "tags": [ + "简历版本" + ], + "summary": "下载导出产物", + "description": "仅本人且任务 SUCCESS 可下载;attachment;不暴露内部路径", + "operationId": "downloadExportTask", + "parameters": [ + { + "name": "taskId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "byte" + } + } + } + } + } + } + }, + "/review-weight-templates": { + "get": { + "tags": [ + "student-review-weight-controller" + ], + "operationId": "getTemplates", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListWeightTemplateResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + } + }, + "/interviews/{interviewId}/score": { + "put": { + "tags": [ + "结构化面试评分" + ], + "summary": "保存评分草稿", + "description": "企业 owner 为已完成的面试保存/更新草稿;草稿阶段五个维度可为空;overallScore 由服务端计算;SUBMITTED 后再次 PUT 返回 409/9503", + "operationId": "saveInterviewScore", + "parameters": [ + { + "name": "interviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InterviewScoreSaveRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "success", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseInterviewScoreResponse" + } + } + } + }, + "400": { + "description": "非法维度或推荐值", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非企业/跨企业/非岗位 owner", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "404": { + "description": "面试不存在 8401", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "409": { + "description": "未完成 9502 / 已提交 9503 / 并发冲突 9501", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + }, + "get": { + "tags": [ + "结构化面试评分" + ], + "summary": "查看评分", + "description": "企业 owner 可看 DRAFT/SUBMITTED;目标学生仅可看 SUBMITTED;无评分返回 404/9504", + "operationId": "getInterviewScore", + "parameters": [ + { + "name": "interviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "success", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseInterviewScoreResponse" + } + } + } + }, + "400": { + "description": "请求体校验失败", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非目标学生 / 跨企业 / 非岗位 owner / ADMIN", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "404": { + "description": "无评分 9504 / 面试不存在 8401", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "409": { + "description": "未完成 9502", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + } + }, + "/interviews/{interviewId}/score/submit": { + "post": { + "tags": [ + "结构化面试评分" + ], + "summary": "提交评分", + "description": "将 DRAFT 提交为 SUBMITTED,提交后不可逆;并发冲突 409;重复提交幂等返回原评分", + "operationId": "submitInterviewScore", + "parameters": [ + { + "name": "interviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "success", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseInterviewScoreResponse" + } + } + } + }, + "400": { + "description": "缺维度 1001", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非企业/跨企业/非岗位 owner", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "404": { + "description": "面试不存在 8401", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "409": { + "description": "未完成 9502 / 并发冲突 9501", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + } + }, + "/interviews/{interviewId}/score/history": { + "get": { + "tags": [ + "结构化面试评分" + ], + "summary": "评分历史", + "description": "企业 owner 可查看全部历史;目标学生仅在 SUBMITTED 后可见;无历史返回 200 空数组;不返回内部用户标识", + "operationId": "getInterviewScoreHistory", + "parameters": [ + { + "name": "interviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "success", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListInterviewScoreHistoryItem" + } + } + } + }, + "401": { + "description": "未登录", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "403": { + "description": "非目标学生 / 跨企业 / 非岗位 owner / ADMIN", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "404": { + "description": "面试不存在 8401", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "409": { + "description": "未完成 9502", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + } + } + }, + "/reports": { + "post": { + "tags": ["举报与屏蔽"], + "summary": "提交举报", + "description": "targetType=USER 必填 targetUserId;MESSAGE 必填 messageId;reportedUserId 由服务端推导。同 reporter+idempotencyKey+相同请求体返回原举报 200;不同请求体 409/9606;同目标同分类已有 PENDING 409/9603。限流:每用户每小时 10 次,超限 429/9609。", + "operationId": "createReport", + "security": [{ "bearerAuth": [] }], + "requestBody": { + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateReportRequest" } + } + }, + "required": true + }, + "responses": { + "200": { "description": "OK", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponseReportResponse" } } } }, + "400": { "description": "BAD_REQUEST(1001)/举报自己 9602/理由缺失 9607", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "403": { "description": "无权访问消息(2002,不泄露消息是否存在)", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "409": { "description": "重复举报 9603 / 幂等冲突 9606", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "429": { "description": "频率超限 9609", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + } + } + }, + "/reports/me": { + "get": { + "tags": ["举报与屏蔽"], + "summary": "我的举报列表", + "description": "仅返回当前用户创建的举报;身份来自 JWT;不返回举报人内部信息/phone/email/passwordHash/resumeUrl/消息全文;支持 status/page/size。", + "operationId": "listMyReports", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "status", "in": "query", "required": false, "schema": { "type": "string" } }, + { "name": "page", "in": "query", "required": false, "schema": { "type": "integer", "format": "int32", "default": 0 } }, + { "name": "size", "in": "query", "required": false, "schema": { "type": "integer", "format": "int32", "default": 20 } } + ], + "responses": { + "200": { "description": "OK", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponsePageResponseReportResponse" } } } }, + "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + } + } + }, + "/reports/{id}": { + "get": { + "tags": ["举报与屏蔽"], + "summary": "举报详情(本人/ADMIN)", + "description": "当前举报人本人或 ADMIN 可查看;其他 403/2002;非 ADMIN 不显示举报人内部 ID/手机号/邮箱;MESSAGE 仅返回脱敏 messagePreview。", + "operationId": "getReportDetail", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "integer", "format": "int64" } } + ], + "responses": { + "200": { "description": "OK", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponseReportResponse" } } } }, + "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "403": { "description": "非本人非 ADMIN(2002)", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "404": { "description": "举报不存在(9601)", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + } + } + }, + "/blocks/{blockedUserId}": { + "put": { + "tags": ["举报与屏蔽"], + "summary": "屏蔽用户", + "description": "屏蔽自己 400/9608;目标不存在复用用户不存在错误码;已存在返回 200 不新增;不影响系统通知/投递/Offer/面试/评分/评价。", + "operationId": "blockUser", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "blockedUserId", "in": "path", "required": true, "schema": { "type": "integer", "format": "int64" } } + ], + "responses": { + "200": { "description": "OK", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponseBlockRelationResponse" } } } }, + "400": { "description": "屏蔽自己 9608", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + } + }, + "delete": { + "tags": ["举报与屏蔽"], + "summary": "解除屏蔽", + "description": "幂等:不存在时返回 200 不报错。", + "operationId": "unblockUser", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "blockedUserId", "in": "path", "required": true, "schema": { "type": "integer", "format": "int64" } } + ], + "responses": { + "200": { "description": "OK(幂等)", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + } + } + }, + "/blocks/me": { + "get": { + "tags": ["举报与屏蔽"], + "summary": "我的屏蔽列表", + "description": "仅返回当前用户主动屏蔽的用户;不返回对方手机号/邮箱/密码/简历地址;支持 page/size。", + "operationId": "listMyBlocks", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "page", "in": "query", "required": false, "schema": { "type": "integer", "format": "int32", "default": 0 } }, + { "name": "size", "in": "query", "required": false, "schema": { "type": "integer", "format": "int32", "default": 20 } } + ], + "responses": { + "200": { "description": "OK", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponsePageResponseBlockRelationResponse" } } } }, + "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + } + } + }, + "/admin/reports": { + "get": { + "tags": ["举报管理"], + "summary": "举报列表(ADMIN)", + "description": "仅 ADMIN;COMPANY/STUDENT/未登录分别 403/403/401;支持 status/targetType/category/page/size;响应脱敏。", + "operationId": "adminListReports", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "status", "in": "query", "required": false, "schema": { "type": "string" } }, + { "name": "targetType", "in": "query", "required": false, "schema": { "type": "string" } }, + { "name": "category", "in": "query", "required": false, "schema": { "type": "string" } }, + { "name": "page", "in": "query", "required": false, "schema": { "type": "integer", "format": "int32", "default": 0 } }, + { "name": "size", "in": "query", "required": false, "schema": { "type": "integer", "format": "int32", "default": 20 } } + ], + "responses": { + "200": { "description": "OK", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponsePageResponseReportResponse" } } } }, + "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "403": { "description": "非 ADMIN", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + } + } + }, + "/admin/reports/{id}": { + "get": { + "tags": ["举报管理"], + "summary": "举报详情(ADMIN)", + "description": "仅 ADMIN;可见类型/分类/理由/脱敏证据 URL/MESSAGE 脱敏内容/会话最小信息/处理历史;不返回密码/JWT/手机号/邮箱/resumeUrl。", + "operationId": "adminGetReport", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "integer", "format": "int64" } } + ], + "responses": { + "200": { "description": "OK", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponseReportResponse" } } } }, + "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "403": { "description": "非 ADMIN", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "404": { "description": "举报不存在(9601)", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + } + } + }, + "/admin/reports/{id}/decision": { + "post": { + "tags": ["举报管理"], + "summary": "举报处理决定(ADMIN)", + "description": "仅 ADMIN;decision 仅 RESOLVED/REJECTED;REJECTED 必填 note;仅 PENDING 可处理,重复 409/9604,非法流转 409/9605;处理后发送一条 REPORT_DECISION 通知。", + "operationId": "decideReport", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "integer", "format": "int64" } } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ReportDecisionRequest" } + } + }, + "required": true + }, + "responses": { + "200": { "description": "OK", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponseReportResponse" } } } }, + "400": { "description": "参数错误(REJECTED 缺 note)", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "401": { "description": "未登录", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "403": { "description": "非 ADMIN", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "404": { "description": "举报不存在(9601)", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }, + "409": { "description": "重复决定 9604 / 非法流转 9605", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } + } + } + } + }, + "components": { + "schemas": { + "AccountDeletionResponse": { + "type": "object", + "properties": { + "userId": { + "type": "integer", + "format": "int64", + "example": 2 + }, + "status": { + "type": "string", + "enum": [ + "NONE", + "PENDING", + "COMPLETED" + ], + "example": "PENDING", + "description": "注销状态:NONE/PENDING/COMPLETED" + }, + "requestedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "effectiveAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "cancelledAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "completedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "version": { + "type": "integer", + "format": "int64", + "example": 0 + } + } + }, + "AdminDashboardOverviewResponse": { + "type": "object", + "properties": { + "totalCompanies": { + "type": "integer", + "format": "int32" + }, + "totalJobs": { + "type": "integer", + "format": "int32" + }, + "totalCandidates": { + "type": "integer", + "format": "int32" + }, + "avgCandidateScore": { + "type": "number" + }, + "topCompanies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanyScoreItem" + } + } + } + }, + "ApiResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": {}, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseAccountDeletionResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "example": 200 + }, + "message": { + "type": "string", + "example": "ok" + }, + "data": { + "$ref": "#/components/schemas/AccountDeletionResponse" + }, + "requestId": { + "type": "string", + "example": "req-1" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseAdminDashboardOverviewResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AdminDashboardOverviewResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseAppealResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AppealResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateDetailView": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateDetailView" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateFeedbackResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateFeedbackResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateListResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateListResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidatePercentileResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidatePercentileResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateRankingDetailResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateRankingDetailResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateRankingListResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateRankingListResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateScoreCardResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateScoreCardResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateScoreTrendResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateScoreTrendResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateScoringOverviewResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateScoringOverviewResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCandidateScoringTrendResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CandidateScoringTrendResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCompanyCandidateScoringResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CompanyCandidateScoringResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCompanyPublicView": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CompanyPublicView" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseDashboardOverviewResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/DashboardOverviewResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseDimensionScoreDetailResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/DimensionScoreDetailResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseJobRankingItem": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/JobRankingItem" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseJobRankingListResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/JobRankingListResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseListAppealHistoryItem": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AppealHistoryItem" + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseListMapStringObject": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseListSnapshotHistoryItem": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotHistoryItem" + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseListWeightTemplateResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WeightTemplateResponse" + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseMapStringObject": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "additionalProperties": {} + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseMapStringString": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponsePersonalizedScoreResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/PersonalizedScoreResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponsePrivacySettingsResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "example": 200 + }, + "message": { + "type": "string", + "example": "ok" + }, + "data": { + "$ref": "#/components/schemas/PrivacySettingsResponse" + }, + "requestId": { + "type": "string", + "example": "req-1" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseReviewWeightResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/ReviewWeightResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseScoreTrendResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/ScoreTrendResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "AppealDecisionRequest": { + "type": "object", + "properties": { + "decision": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string" + } + }, + "required": [ + "decision" + ] + }, + "AppealHistoryItem": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "fromStatus": { + "type": "string" + }, + "toStatus": { + "type": "string" + }, + "actorUserId": { + "type": "integer", + "format": "int64" + }, + "actorRole": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "AppealResponse": { + "type": "object", + "properties": { + "appealId": { + "type": "integer", + "format": "int64" + }, + "candidateId": { + "type": "integer", + "format": "int64" + }, + "dimension": { + "type": "string" + }, + "period": { + "type": "string" + }, + "status": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "decision": { + "type": "string" + }, + "decisionReason": { + "type": "string" + }, + "reviewerId": { + "type": "integer", + "format": "int64" + }, + "reviewedAt": { + "type": "string", + "format": "date-time" + }, + "rollbackAt": { + "type": "string", + "format": "date-time" + }, + "scoreAtAppeal": { + "type": "number" + }, + "snapshotVersionAtAppeal": { + "type": "integer", + "format": "int64" + }, + "rulesVersion": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ApplicationCreateRequest": { + "type": "object", + "properties": { + "jobId": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "jobId" + ] + }, + "ApplicationStatusRequest": { + "type": "object", + "properties": { + "status": { + "type": "string", + "minLength": 1, + "pattern": "^(VIEWED|INTERVIEW|REJECTED|HIRED)$" + } + }, + "required": [ + "status" + ] + }, + "CandidateDetailView": { + "type": "object", + "properties": { + "applicationId": { + "type": "integer", + "format": "int64" + }, + "jobId": { + "type": "integer", + "format": "int64" + }, + "jobTitle": { + "type": "string" + }, + "jobCity": { + "type": "string" + }, + "jobEmploymentType": { + "type": "string" + }, + "status": { + "type": "string" + }, + "coverLetter": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "candidateName": { + "type": "string" + }, + "candidateAvatarUrl": { + "type": "string" + }, + "candidateBio": { + "type": "string" + }, + "candidateLocation": { + "type": "string" + }, + "candidateSchool": { + "type": "string" + }, + "candidateEducation": { + "type": "string" + }, + "candidateGraduationYear": { + "type": "integer", + "format": "int32" + }, + "candidateSkills": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CandidateFeedbackRequest": { + "type": "object", + "properties": { + "rating": { + "type": "integer", + "format": "int32", + "maximum": 5, + "minimum": 1 + }, + "content": { + "type": "string", + "maxLength": 1000, + "minLength": 0 + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 50, + "minLength": 1 + }, + "maxItems": 10, + "minItems": 0 + } + }, + "required": [ + "content", + "rating" + ] + }, + "CandidateFeedbackResponse": { + "type": "object", + "description": "脱敏后的候选人反馈", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "description": "反馈 ID", + "example": 1 + }, + "applicationId": { + "type": "integer", + "format": "int64", + "description": "投递 ID", + "example": 31 + }, + "rating": { + "type": "integer", + "format": "int32", + "description": "评分,范围 1-5", + "example": 5 + }, + "content": { + "type": "string", + "description": "反馈内容", + "example": "沟通良好" + }, + "tags": { + "type": "array", + "description": "反馈标签", + "items": { + "type": "string" + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "创建时间", + "example": "2026-08-25T10:00:00" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "更新时间", + "example": "2026-08-25T10:00:00" + } + } + }, + "CandidateFeedbackSummary": { + "type": "object", + "properties": { + "totalCount": { + "type": "integer", + "format": "int32" + }, + "averageRating": { + "type": "number" + }, + "ratingDistribution": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagCount" + } + } + } + }, + "CandidateItemView": { + "type": "object", + "properties": { + "applicationId": { + "type": "integer", + "format": "int64" + }, + "jobId": { + "type": "integer", + "format": "int64" + }, + "jobTitle": { + "type": "string" + }, + "status": { + "type": "string" + }, + "candidateName": { + "type": "string" + }, + "candidateSchool": { + "type": "string" + }, + "candidateEducation": { + "type": "string" + }, + "candidateGraduationYear": { + "type": "integer", + "format": "int32" + }, + "candidateAvatarUrl": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CandidateListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CandidateItemView" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "CandidateMatchSignals": { + "type": "object", + "properties": { + "candidateInterestScore": { + "$ref": "#/components/schemas/MatchSignal" + }, + "hrSuitabilityScore": { + "$ref": "#/components/schemas/MatchSignal" + } + } + }, + "CandidatePercentileResponse": { + "type": "object", + "properties": { + "candidateId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "scoreType": { + "type": "string" + }, + "score": { + "type": "number" + }, + "percentile": { + "type": "number" + }, + "cohortSize": { + "type": "integer", + "format": "int32" + }, + "sampleCount": { + "type": "integer", + "format": "int32" + }, + "minimumSampleCount": { + "type": "integer", + "format": "int32" + } + } + }, + "CandidateRankingDetailResponse": { + "type": "object", + "properties": { + "applicationId": { + "type": "integer", + "format": "int64" + }, + "jobId": { + "type": "integer", + "format": "int64" + }, + "candidateName": { + "type": "string" + }, + "candidateSchool": { + "type": "string" + }, + "overallScore": { + "type": "number" + }, + "percentile": { + "type": "number" + }, + "cohortSize": { + "type": "integer", + "format": "int32" + }, + "dimensionScores": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DimensionScoreItem" + } + } + } + }, + "CandidateRankingItem": { + "type": "object", + "properties": { + "applicationId": { + "type": "integer", + "format": "int64" + }, + "jobId": { + "type": "integer", + "format": "int64" + }, + "candidateName": { + "type": "string" + }, + "candidateSchool": { + "type": "string" + }, + "overallScore": { + "type": "number" + }, + "percentile": { + "type": "number" + }, + "sampleCount": { + "type": "integer", + "format": "int32" + } + } + }, + "CandidateRankingListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CandidateRankingItem" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "CandidateScoreCardResponse": { + "type": "object", + "properties": { + "candidateId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "overallScore": { + "type": "number" + }, + "sampleCount": { + "type": "integer", + "format": "int32" + }, + "feedbackSummary": { + "$ref": "#/components/schemas/CandidateFeedbackSummary" + }, + "matchSignals": { + "$ref": "#/components/schemas/CandidateMatchSignals" + }, + "unsupportedDimensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "generatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CandidateScoreTrendItem": { + "type": "object", + "properties": { + "month": { + "type": "string" + }, + "feedbackCount": { + "type": "integer", + "format": "int32" + }, + "averageRating": { + "type": "number" + }, + "ratingScore": { + "type": "number" + } + } + }, + "CandidateScoreTrendResponse": { + "type": "object", + "properties": { + "candidateId": { + "type": "integer", + "format": "int64" + }, + "months": { + "type": "integer", + "format": "int32" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CandidateScoreTrendItem" + } + } + } + }, + "CandidateScoringOverviewResponse": { + "type": "object", + "properties": { + "candidateId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "overallScore": { + "type": "number" + }, + "weights": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "dimensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DimensionItem" + } + }, + "rulesVersion": { + "type": "string" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CandidateScoringTrendResponse": { + "type": "object", + "properties": { + "candidateId": { + "type": "integer", + "format": "int64" + }, + "months": { + "type": "integer", + "format": "int32" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TrendItem" + } + } + } + }, + "CompanyCandidateScoringResponse": { + "type": "object", + "properties": { + "applicationId": { + "type": "integer", + "format": "int64" + }, + "overallScore": { + "type": "number" + }, + "status": { + "type": "string" + }, + "dimensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanyDimensionItem" + } + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "rulesVersion": { + "type": "string" + } + } + }, + "CompanyCreateRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 100, + "minLength": 0 + }, + "logoUrl": { + "type": "string" + }, + "description": { + "type": "string" + }, + "industry": { + "type": "string" + }, + "website": { + "type": "string" + }, + "address": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "CompanyDimensionItem": { + "type": "object", + "properties": { + "dimension": { + "type": "string" + }, + "score": { + "type": "number" + }, + "status": { + "type": "string" + } + } + }, + "CompanyMatchRuleRequest": { + "type": "object", + "properties": { + "requiredCandidateScore": { + "type": "integer", + "format": "int32", + "maximum": 100, + "minimum": 0 + } + }, + "required": [ + "requiredCandidateScore" + ] + }, + "CompanyPublicView": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "logoUrl": { + "type": "string" + }, + "description": { + "type": "string" + }, + "industry": { + "type": "string" + }, + "verified": { + "type": "boolean" + } + } + }, + "CompanyScoreItem": { + "type": "object", + "properties": { + "companyId": { + "type": "integer", + "format": "int64" + }, + "companyName": { + "type": "string" + }, + "avgCandidateScore": { + "type": "number" + }, + "jobCount": { + "type": "integer", + "format": "int32" + } + } + }, + "CompanyUpdateRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 120, + "minLength": 0 + }, + "logoUrl": { + "type": "string", + "maxLength": 500, + "minLength": 0 + }, + "description": { + "type": "string" + }, + "industry": { + "type": "string", + "maxLength": 80, + "minLength": 0 + }, + "website": { + "type": "string", + "maxLength": 255, + "minLength": 0 + }, + "address": { + "type": "string", + "maxLength": 255, + "minLength": 0 + } + }, + "required": [ + "name" + ] + }, + "CounterOfferCreateRequest": { + "type": "object", + "properties": { + "proposedSalaryMin": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "proposedSalaryMax": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "proposedOnboardingDate": { + "type": "string", + "format": "date" + }, + "reason": { + "type": "string", + "maxLength": 2000, + "minLength": 0 + } + } + }, + "CreateAppealRequest": { + "type": "object", + "properties": { + "dimension": { + "type": "string", + "minLength": 1 + }, + "period": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string" + }, + "idempotencyKey": { + "type": "string", + "maxLength": 64, + "minLength": 0 + } + }, + "required": [ + "dimension", + "period" + ] + }, + "CreateConversationRequest": { + "type": "object", + "properties": { + "participantUserId": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "participantUserId" + ] + }, + "DashboardOverviewResponse": { + "type": "object", + "properties": { + "companyId": { + "type": "integer", + "format": "int64" + }, + "totalJobs": { + "type": "integer", + "format": "int32" + }, + "publishedJobs": { + "type": "integer", + "format": "int32" + }, + "avgJobScore": { + "type": "number" + }, + "totalCandidates": { + "type": "integer", + "format": "int32" + }, + "avgCandidateScore": { + "type": "number" + }, + "scoreDistribution": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "DataSourceItem": { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "time": { + "type": "string", + "format": "date-time" + }, + "note": { + "type": "string" + } + } + }, + "DimensionItem": { + "type": "object", + "properties": { + "dimension": { + "type": "string" + }, + "score": { + "type": "number" + }, + "status": { + "type": "string" + }, + "weight": { + "type": "number" + }, + "summary": { + "type": "string" + } + } + }, + "DimensionScoreDetailResponse": { + "type": "object", + "properties": { + "dimension": { + "type": "string" + }, + "score": { + "type": "number" + }, + "status": { + "type": "string" + }, + "subScores": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SubScore" + } + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "dataSources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DataSourceItem" + } + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "rulesVersion": { + "type": "string" + } + } + }, + "DimensionScoreItem": { + "type": "object", + "properties": { + "dimension": { + "type": "string" + }, + "score": { + "type": "number" + }, + "status": { + "type": "string" + } + } + }, + "InterviewCancelRequest": { + "type": "object", + "properties": { + "reason": { + "type": "string" + } + } + }, + "InterviewCreateRequest": { + "type": "object", + "properties": { + "roundNo": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "maxLength": 200, + "minLength": 0 + }, + "interviewType": { + "type": "string", + "minLength": 1 + }, + "startAt": { + "type": "string", + "format": "date-time" + }, + "endAt": { + "type": "string", + "format": "date-time" + }, + "timezone": { + "type": "string" + }, + "meetingUrl": { + "type": "string" + }, + "address": { + "type": "string" + }, + "interviewerName": { + "type": "string", + "maxLength": 100, + "minLength": 0 + }, + "notes": { + "type": "string" + } + }, + "required": [ + "endAt", + "interviewType", + "startAt", + "title" + ] + }, + "InterviewRespondRequest": { + "type": "object", + "properties": { + "action": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string" + } + }, + "required": [ + "action" + ] + }, + "InterviewUpdateRequest": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "startAt": { + "type": "string", + "format": "date-time" + }, + "endAt": { + "type": "string", + "format": "date-time" + }, + "timezone": { + "type": "string" + }, + "meetingUrl": { + "type": "string" + }, + "address": { + "type": "string" + }, + "interviewerName": { + "type": "string" + }, + "notes": { + "type": "string" + }, + "timeRangeValid": { + "type": "boolean" + } + } + }, + "JobCreateRequest": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 120, + "minLength": 0 + }, + "description": { + "type": "string", + "minLength": 1 + }, + "requirements": { + "type": "string" + }, + "city": { + "type": "string", + "maxLength": 80, + "minLength": 0 + }, + "employmentType": { + "type": "string", + "maxLength": 30, + "minLength": 0 + }, + "salaryMin": { + "type": "integer", + "format": "int32" + }, + "salaryMax": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "city", + "description", + "employmentType", + "title" + ] + }, + "JobRankingItem": { + "type": "object", + "properties": { + "jobId": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string" + }, + "city": { + "type": "string" + }, + "employmentType": { + "type": "string" + }, + "avgRating": { + "type": "number" + }, + "reviewCount": { + "type": "integer", + "format": "int32" + }, + "rank": { + "type": "integer", + "format": "int32" + }, + "cohortSize": { + "type": "integer", + "format": "int32" + }, + "dimensionAverages": { + "type": "object", + "additionalProperties": { + "type": "number" + } + } + } + }, + "JobRankingListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JobRankingItem" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "JobReviewCreateRequest": { + "type": "object", + "properties": { + "rating": { + "type": "integer", + "format": "int32", + "maximum": 5, + "minimum": 1 + }, + "content": { + "type": "string", + "maxLength": 1000, + "minLength": 0 + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 50, + "minLength": 1 + }, + "maxItems": 10, + "minItems": 0 + }, + "dimensionScores": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + } + }, + "required": [ + "content", + "rating" + ] + }, + "JobReviewUpdateRequest": { + "type": "object", + "properties": { + "rating": { + "type": "integer", + "format": "int32", + "maximum": 5, + "minimum": 1 + }, + "content": { + "type": "string", + "maxLength": 1000, + "minLength": 1 + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 50, + "minLength": 1 + }, + "maxItems": 10, + "minItems": 0 + }, + "status": { + "type": "string", + "pattern": "^(PUBLISHED|HIDDEN)$" + }, + "dimensionScores": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + } + } + }, + "JobStatusRequest": { + "type": "object", + "properties": { + "status": { + "type": "string", + "minLength": 1, + "pattern": "^(PUBLISHED|CLOSED)$" + } + }, + "required": [ + "status" + ] + }, + "JobUpdateRequest": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 120, + "minLength": 0 + }, + "description": { + "type": "string" + }, + "requirements": { + "type": "string" + }, + "city": { + "type": "string", + "maxLength": 80, + "minLength": 0 + }, + "employmentType": { + "type": "string", + "maxLength": 30, + "minLength": 0 + }, + "salaryMin": { + "type": "integer", + "format": "int32" + }, + "salaryMax": { + "type": "integer", + "format": "int32" + } + } + }, + "LoginRequest": { + "type": "object", + "properties": { + "phone": { + "type": "string", + "minLength": 1, + "pattern": "[0-9]{11}" + }, + "code": { + "type": "string", + "minLength": 1, + "pattern": "[0-9]{6}" + }, + "purpose": { + "type": "string" + } + }, + "required": [ + "code", + "phone" + ] + }, + "MatchSignal": { + "type": "object", + "properties": { + "average": { + "type": "number" + }, + "sampleCount": { + "type": "integer", + "format": "int32" + } + } + }, + "OfferCreateRequest": { + "type": "object", + "properties": { + "applicationId": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string", + "maxLength": 120, + "minLength": 0 + }, + "salaryMin": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "salaryMax": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "salaryCurrency": { + "type": "string", + "maxLength": 10, + "minLength": 0 + }, + "salaryPeriod": { + "type": "string", + "pattern": "^(MONTH|YEAR|HOUR)$" + }, + "workCity": { + "type": "string", + "maxLength": 80, + "minLength": 0 + }, + "employmentType": { + "type": "string", + "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$" + }, + "probationMonths": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "benefits": { + "type": "string", + "maxLength": 2000, + "minLength": 0 + }, + "description": { + "type": "string", + "maxLength": 5000, + "minLength": 0 + }, + "expireAt": { + "type": "string", + "format": "date-time" + }, + "onboardingDate": { + "type": "string", + "format": "date" + }, + "baseSalary": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "housingAllowance": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "annualBonusMonths": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "otherAllowances": { + "type": "string", + "maxLength": 1000, + "minLength": 0 + } + }, + "required": [ + "applicationId", + "description", + "expireAt", + "title", + "workCity" + ] + }, + "OfferStatusRequest": { + "type": "object", + "properties": { + "status": { + "type": "string", + "minLength": 1, + "pattern": "^(SENT|NEGOTIATING|ACCEPTED|REJECTED|EXPIRED|WITHDRAWN)$" + }, + "reason": { + "type": "string", + "maxLength": 255, + "minLength": 0 + } + }, + "required": [ + "status" + ] + }, + "OfferUpdateRequest": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 120, + "minLength": 0 + }, + "salaryMin": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "salaryMax": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "salaryCurrency": { + "type": "string", + "maxLength": 10, + "minLength": 0 + }, + "salaryPeriod": { + "type": "string", + "pattern": "^(MONTH|YEAR|HOUR)$" + }, + "workCity": { + "type": "string", + "maxLength": 80, + "minLength": 0 + }, + "employmentType": { + "type": "string", + "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$" + }, + "probationMonths": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "benefits": { + "type": "string", + "maxLength": 2000, + "minLength": 0 + }, + "description": { + "type": "string", + "maxLength": 5000, + "minLength": 0 + }, + "expireAt": { + "type": "string", + "format": "date-time" + }, + "onboardingDate": { + "type": "string", + "format": "date" + }, + "baseSalary": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "housingAllowance": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "annualBonusMonths": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "otherAllowances": { + "type": "string", + "maxLength": 1000, + "minLength": 0 + } + } + }, + "PersonalizedScoreResponse": { + "type": "object", + "properties": { + "jobId": { + "type": "integer", + "format": "int64" + }, + "score": { + "type": "number" + }, + "status": { + "type": "string" + }, + "source": { + "type": "string" + }, + "templateCode": { + "type": "string" + }, + "configuredWeights": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "effectiveWeights": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "dimensionScores": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "coveredWeight": { + "type": "number" + }, + "missingDimensions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PrivacySettingsResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1, + "nullable": true + }, + "studentUserId": { + "type": "integer", + "format": "int64", + "example": 2 + }, + "resumeVisibility": { + "$ref": "#/components/schemas/ResumeVisibility", + "description": "简历可见性(PRIVATE/APPLIED_COMPANIES/VERIFIED_COMPANIES)", + "example": "APPLIED_COMPANIES" + }, + "contactVisible": { + "type": "boolean", + "example": true + }, + "version": { + "type": "integer", + "format": "int64", + "example": 0 + }, + "createdAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, + "PrivacySettingsUpdateRequest": { + "type": "object", + "required": [ + "resumeVisibility", + "contactVisible" + ], + "properties": { + "resumeVisibility": { + "$ref": "#/components/schemas/ResumeVisibility", + "description": "简历可见性(必填)", + "example": "APPLIED_COMPANIES" + }, + "contactVisible": { + "type": "boolean", + "description": "联系方式是否对企业可见(必填)", + "example": true + }, + "version": { + "type": "integer", + "format": "int64", + "description": "乐观锁版本号(可选)", + "example": 0 + } + } + }, + "ResumeVisibility": { + "type": "string", + "enum": [ + "PRIVATE", + "APPLIED_COMPANIES", + "VERIFIED_COMPANIES" + ], + "description": "简历可见性:PRIVATE 私有;APPLIED_COMPANIES 仅对企业有效投递可见;VERIFIED_COMPANIES 仅对认证企业可见", + "example": "PRIVATE" + }, + "ReviewDecisionRequest": { + "type": "object", + "properties": { + "decision": { + "type": "string", + "minLength": 1, + "pattern": "^(APPROVED|REJECTED)$" + }, + "reason": { + "type": "string", + "maxLength": 500, + "minLength": 0 + } + }, + "required": [ + "decision" + ] + }, + "ReviewWeightResponse": { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "templateCode": { + "type": "string" + }, + "configuredWeights": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "SaveWeightRequest": { + "type": "object", + "properties": { + "templateCode": { + "type": "string" + }, + "weights": { + "type": "object", + "additionalProperties": { + "type": "number" + } + } + } + }, + "ScoreRequest": { + "type": "object", + "properties": { + "score": { + "type": "integer", + "format": "int32", + "maximum": 100, + "minimum": 0 + } + }, + "required": [ + "score" + ] + }, + "ScoreTrendResponse": { + "type": "object", + "properties": { + "companyId": { + "type": "integer", + "format": "int64" + }, + "months": { + "type": "integer", + "format": "int32" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TrendItem" + } + } + } + }, + "SendMessageRequest": { + "type": "object", + "properties": { + "conversationId": { + "type": "integer", + "format": "int64" + }, + "content": { + "type": "string", + "maxLength": 2000, + "minLength": 0 + } + }, + "required": [ + "content", + "conversationId" + ] + }, + "SnapshotHistoryItem": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "appealId": { + "type": "integer", + "format": "int64" + }, + "action": { + "type": "string" + }, + "dimension": { + "type": "string" + }, + "period": { + "type": "string" + }, + "beforeScore": { + "type": "number" + }, + "afterScore": { + "type": "number" + }, + "beforeVersion": { + "type": "integer", + "format": "int64" + }, + "afterVersion": { + "type": "integer", + "format": "int64" + }, + "beforeRulesVersion": { + "type": "string" + }, + "afterRulesVersion": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "StudentMatchRuleRequest": { + "type": "object", + "properties": { + "requiredHrScore": { + "type": "integer", + "format": "int32", + "maximum": 100, + "minimum": 0 + } + }, + "required": [ + "requiredHrScore" + ] + }, + "StudentPreferenceRequest": { + "type": "object", + "properties": { + "expectedSalaryMin": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "expectedSalaryMax": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "preferredCities": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "maxItems": 20, + "minItems": 0 + }, + "employmentTypes": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "maxItems": 20, + "minItems": 0 + }, + "preferredIndustries": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "maxItems": 20, + "minItems": 0 + }, + "preferredJobTypes": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "maxItems": 20, + "minItems": 0 + }, + "remoteAllowed": { + "type": "boolean" + }, + "salaryRangeValid": { + "type": "boolean" + } + } + }, + "StudentProfileUpdateRequest": { + "type": "object", + "properties": { + "realName": { + "type": "string", + "maxLength": 50, + "minLength": 0 + }, + "bio": { + "type": "string" + }, + "location": { + "type": "string", + "maxLength": 100, + "minLength": 0 + }, + "education": { + "type": "string", + "maxLength": 100, + "minLength": 0 + }, + "school": { + "type": "string", + "maxLength": 150, + "minLength": 0 + }, + "graduationYear": { + "type": "integer", + "format": "int32" + }, + "resumeUrl": { + "type": "string", + "maxLength": 500, + "minLength": 0 + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "SubScore": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "score": { + "type": "number" + }, + "weight": { + "type": "number" + }, + "description": { + "type": "string" + } + } + }, + "TagCount": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "count": { + "type": "integer", + "format": "int32" + } + } + }, + "TrendItem": { + "type": "object", + "properties": { + "period": { + "type": "string" + }, + "overallScore": { + "type": "number" + }, + "rulesVersion": { + "type": "string" + }, + "dimensions": { + "type": "object", + "additionalProperties": { + "type": "number" + } + } + } + }, + "VerificationDecisionRequest": { + "type": "object", + "properties": { + "decision": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string" + } + }, + "required": [ + "decision" + ] + }, + "VerificationSubmitRequest": { + "type": "object", + "properties": { + "materialsJson": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "materialsJson" + ] + }, + "WeightTemplateResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "weights": { + "type": "object", + "additionalProperties": { + "type": "number" + } + } + } + }, + "InterviewScoreSaveRequest": { + "type": "object", + "properties": { + "technicalScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true + }, + "communicationScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true + }, + "problemSolvingScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true + }, + "roleFitScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true + }, + "professionalismScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true + }, + "recommendation": { + "type": "string", + "enum": [ + "STRONG_HIRE", + "HIRE", + "NEUTRAL", + "NO_HIRE" + ], + "nullable": true, + "description": "企业手动选择,不根据 overallScore 自动推导" + }, + "comment": { + "type": "string", + "nullable": true + } + }, + "description": "评分草稿保存请求;overallScore 由服务端计算,客户端不得传入" + }, + "InterviewScoreResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "interviewId": { + "type": "integer", + "format": "int64" + }, + "applicationId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string", + "enum": [ + "DRAFT", + "SUBMITTED" + ] + }, + "technicalScore": { + "type": "integer", + "nullable": true + }, + "communicationScore": { + "type": "integer", + "nullable": true + }, + "problemSolvingScore": { + "type": "integer", + "nullable": true + }, + "roleFitScore": { + "type": "integer", + "nullable": true + }, + "professionalismScore": { + "type": "integer", + "nullable": true + }, + "overallScore": { + "type": "number", + "format": "decimal", + "nullable": true, + "description": "由服务端 BigDecimal 计算:(t+c+ps+rf+p)/5,保留两位小数 HALF_UP" + }, + "recommendation": { + "type": "string", + "enum": [ + "STRONG_HIRE", + "HIRE", + "NEUTRAL", + "NO_HIRE" + ], + "nullable": true + }, + "comment": { + "type": "string", + "nullable": true + }, + "submittedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "version": { + "type": "integer", + "format": "int64" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "description": "面试评分响应(脱敏:不返回 companyUserId/studentUserId/evaluatorUserId/手机号/邮箱)" + }, + "ApiResponseInterviewScoreResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/InterviewScoreResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "InterviewScoreHistoryItem": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "scoreId": { + "type": "integer", + "format": "int64" + }, + "interviewId": { + "type": "integer", + "format": "int64" + }, + "fromStatus": { + "type": "string", + "enum": [ + "DRAFT", + "SUBMITTED" + ], + "nullable": true + }, + "toStatus": { + "type": "string", + "enum": [ + "DRAFT", + "SUBMITTED" + ] + }, + "actorRole": { + "type": "string", + "example": "COMPANY" + }, + "snapshot": { + "type": "object", + "additionalProperties": {} + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "description": "评分历史条目(脱敏:不返回 actorUserId/手机号/邮箱)" + }, + "ApiResponseListInterviewScoreHistoryItem": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InterviewScoreHistoryItem" + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "CreateReportRequest": { + "type": "object", + "properties": { + "targetType": { "type": "string", "enum": ["USER", "MESSAGE"], "description": "举报目标类型;USER 必填 targetUserId,MESSAGE 必填 messageId" }, + "targetUserId": { "type": "integer", "format": "int64", "description": "targetType=USER 时必填" }, + "messageId": { "type": "integer", "format": "int64", "description": "targetType=MESSAGE 时必填" }, + "category": { "type": "string", "maxLength": 40, "description": "举报分类,如 HARASSMENT" }, + "reason": { "type": "string", "minLength": 1, "maxLength": 1000 }, + "evidenceUrls": { "type": "array", "items": { "type": "string", "maxLength": 500 }, "description": "最多 5 个,仅 http/https,最多 500 字符" }, + "idempotencyKey": { "type": "string", "maxLength": 64, "description": "幂等键(可选)" } + } + }, + "ReportDecisionRequest": { + "type": "object", + "properties": { + "decision": { "type": "string", "enum": ["RESOLVED", "REJECTED"] }, + "note": { "type": "string", "maxLength": 1000, "description": "REJECTED 必填" } + } + }, + "ReportResponse": { + "type": "object", + "properties": { + "id": { "type": "integer", "format": "int64" }, + "targetType": { "type": "string", "enum": ["USER", "MESSAGE"] }, + "targetUserId": { "type": "integer", "format": "int64" }, + "messageId": { "type": "integer", "format": "int64" }, + "reportedUserId": { "type": "integer", "format": "int64" }, + "category": { "type": "string" }, + "reason": { "type": "string" }, + "evidenceUrls": { "type": "array", "items": { "type": "string" } }, + "status": { "type": "string", "enum": ["PENDING", "RESOLVED", "REJECTED"] }, + "decision": { "type": "string", "enum": ["RESOLVED", "REJECTED"] }, + "decisionNote": { "type": "string" }, + "decidedAt": { "type": "string", "format": "date-time" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "messagePreview": { "type": "string", "description": "MESSAGE 举报的脱敏消息预览,不返回会话其他敏感内容" }, + "reportedUser": { "$ref": "#/components/schemas/ReportResponseReportedUser" }, + "history": { "type": "array", "items": { "$ref": "#/components/schemas/ReportResponseDecisionHistoryItem" } } + } + }, + "ReportResponseReportedUser": { + "type": "object", + "properties": { + "userId": { "type": "integer", "format": "int64" }, + "nickname": { "type": "string" }, + "role": { "type": "string" } + } + }, + "ReportResponseDecisionHistoryItem": { + "type": "object", + "properties": { + "id": { "type": "integer", "format": "int64" }, + "fromStatus": { "type": "string" }, + "toStatus": { "type": "string" }, + "actorUserId": { "type": "integer", "format": "int64" }, + "actorRole": { "type": "string" }, + "note": { "type": "string" }, + "createdAt": { "type": "string", "format": "date-time" } + } + }, + "BlockRelationResponse": { + "type": "object", + "properties": { + "id": { "type": "integer", "format": "int64" }, + "blockedUserId": { "type": "integer", "format": "int64" }, + "nickname": { "type": "string" }, + "createdAt": { "type": "string", "format": "date-time" } + } + }, + "ApiResponseReportResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", "format": "int32" }, + "message": { "type": "string" }, + "data": { "$ref": "#/components/schemas/ReportResponse" }, + "requestId": { "type": "string" }, + "timestamp": { "type": "string", "format": "date-time" } + } + }, + "ApiResponsePageResponseReportResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", "format": "int32" }, + "message": { "type": "string" }, + "data": { + "type": "object", + "properties": { + "items": { "type": "array", "items": { "$ref": "#/components/schemas/ReportResponse" } }, + "totalElements": { "type": "integer", "format": "int64" }, + "page": { "type": "integer", "format": "int32" }, + "size": { "type": "integer", "format": "int32" } + } + }, + "requestId": { "type": "string" }, + "timestamp": { "type": "string", "format": "date-time" } + } + }, + "ApiResponseBlockRelationResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", "format": "int32" }, + "message": { "type": "string" }, + "data": { "$ref": "#/components/schemas/BlockRelationResponse" }, + "requestId": { "type": "string" }, + "timestamp": { "type": "string", "format": "date-time" } + } + }, + "ApiResponsePageResponseBlockRelationResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", "format": "int32" }, + "message": { "type": "string" }, + "data": { + "type": "object", + "properties": { + "items": { "type": "array", "items": { "$ref": "#/components/schemas/BlockRelationResponse" } }, + "totalElements": { "type": "integer", "format": "int64" }, + "page": { "type": "integer", "format": "int32" }, + "size": { "type": "integer", "format": "int32" } + } + }, + "requestId": { "type": "string" }, + "timestamp": { "type": "string", "format": "date-time" } + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "description": "通过 /auth/login 获取的 Access Token,请求头格式:Authorization: Bearer <token>", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + } +} \ No newline at end of file diff --git a/docs/personal-data-export-api.md b/docs/personal-data-export-api.md new file mode 100644 index 0000000..2eeafc4 --- /dev/null +++ b/docs/personal-data-export-api.md @@ -0,0 +1,204 @@ +# 个人数据导出 API(V30) + +本文档描述「个人数据导出」模块。**这与简历导出(`/resume-export-tasks`)是两套完全独立的机制**,请勿混淆: + +- 简历导出:面向单个简历文件/快照的导出,走既有 `/resume-export-tasks` 接口,语义保持不变。 +- 个人数据导出:把当前登录用户的全量业务数据打包成一个异步 ZIP 文件下载,走下面的新接口。 + +只实现当前登录用户「本人」数据的导出;任务创建是**异步**的:先返回一个 `PENDING` 任务,后台 Worker 收集并打包,完成后才能下载。 + +--- + +## 一、接口总览 + +| 操作 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/profile/me/data-export-tasks` | 创建导出任务(异步 ZIP) | +| GET | `/profile/me/data-export-tasks` | 查询本人任务列表(分页) | +| GET | `/profile/me/data-export-tasks/{taskId}` | 查询本人某个任务详情 | +| GET | `/profile/me/data-export-tasks/{taskId}/download` | 下载已完成 ZIP | + +新增 3 个 path、4 个 operation。OpenAPI 路径总数 110 → **113**(详情接口已覆盖状态查询,未单独新增 `/status`)。 + +### 身份与权限 + +- 所有接口都需要登录(JWT);身份只从 JWT/SecurityContext 获取,**不接受 `userId`/`candidateId` 参数**。 +- `STUDENT`、`COMPANY`、`ADMIN` 均可导出**自己的**任务。 +- 只能查询/下载自己的任务;访问他人任务返回 `403/2002`,任务不存在返回 `404/9401`。 + +--- + +## 二、创建任务 + +`POST /profile/me/data-export-tasks` + +请求体(均可选): + +```json +{ "format": "ZIP", "idempotencyKey": "optional-client-key" } +``` + +约束: + +- `format` 当前仅允许 `ZIP`(非法值返回 `400/1001`)。 +- `idempotencyKey` 可选,最大 64 字符;用于创建幂等重放。 +- 同一用户存在未完成任务(`PENDING`/`PROCESSING`)时: + - 相同 `idempotencyKey` 重放 → 返回原任务; + - 不同 key → 返回 `409/9402`。 +- 已完成且未过期的旧任务 → 直接返回该任务;若旧任务已过期则可创建新任务。 +- 账号注销 `PENDING`/`COMPLETED` 时禁止创建(见第八节)。 + +成功响应: + +```json +{ + "code": 200, + "message": "ok", + "data": { + "taskId": 1, + "status": "PENDING", + "format": "ZIP", + "requestedAt": "2026-08-25T10:00:00", + "startedAt": null, + "completedAt": null, + "expiresAt": null, + "sizeBytes": null, + "downloadAvailable": false + } +} +``` + +> 响应**绝不**包含 `storageKey`、服务器绝对路径、内部异常堆栈等字段。 + +--- + +## 三、查询任务列表 / 详情 + +`GET /profile/me/data-export-tasks?page=0&size=20&status=` + +- `page` 默认 0,不得小于 0。 +- `size` 默认 20,范围 1–100(越界返回 `400/1001`)。 +- `status` 可选:`PENDING` / `PROCESSING` / `COMPLETED` / `FAILED` / `EXPIRED` / `CANCELLED`。 +- 按 `createdAt` **倒序**,返回标准分页结构。 + +`GET /profile/me/data-export-tasks/{taskId}` + +- 任务不存在 → `404/9401`。 +- 任务属于他人 → `403/2002`。 +- 响应不返回 `storageKey`、绝对路径、内部字段。 + +--- + +## 四、下载 + +`GET /profile/me/data-export-tasks/{taskId}/download` + +- 仅任务本人可下载。 +- 仅 `COMPLETED` **且未过期**可下载。 +- `PENDING`/`PROCESSING` → `409/9403`(文件尚未准备好了)。 +- `FAILED` → `409/9405`。 +- `EXPIRED`/`CANCELLED` → `409/9404`。 +- 文件不存在 → `409/9406`(不会返回 500)。 +- 返回 `Content-Disposition: attachment`,`Content-Type: application/octet-stream`(内容为 ZIP)。 +- **下载凭证不可脱离 JWT**;不返回任何公开 URL。 +- 下载成功会记录 `downloadedAt` 并写审计事件 `DATA_EXPORT_DOWNLOADED`(留有接口;服务记录 downloadedAt)。 + +--- + +## 五、任务状态机 + +``` +PENDING ─→ PROCESSING ─→ COMPLETED +PENDING ─→ PROCESSING ─→ FAILED +PENDING ─→ FAILED +PROCESSING ─→ FAILED +COMPLETED ─→ EXPIRED +COMPLETED/CANCELLED(账号注销清理) +``` + +- 创建后默认 `PENDING`;后台 Worker 用 `@Version` 乐观锁抢占(只有一个 Worker 能把 `PENDING→PROCESSING`,并发后到者跳过)。 +- 完成后写入 `completedAt`、`expiresAt`、`sizeBytes`、`checksumSha256`。 +- 默认文件有效期 **24 小时**,过期由定时任务把 `COMPLETED→EXPIRED` 并删除本地导出文件;**不删除任何业务数据库数据**。 +- 失败仅记录安全的 `errorCode/errorMessage`,不记录密码、Token、手机号等敏感内容。 + +--- + +## 六、导出数据范围 + +ZIP 内部结构(`personal-data/` 根目录): + +``` +personal-data/ + manifest.json # schemaVersion/format/requestedAt/fileCount/sections + profile.json # 基本资料 + 学生资料 + preferences.json # 求职偏好 + resumes/<文件> # 用户自己上传的简历文件 + resumes.json 元数据 + applications.json # 投递记录 + application-timelines.json + interviews.json # 本人参与/发起的面试 + interview-timelines.json + offers.json # 本人收到或创建(COMPANY)的 Offer + offer-timelines.json + feedback.json # 本人候选人反馈 + favorites.json # 本人岗位收藏 + notifications.json # 本人通知 + conversations.json # 本人会话 + messages.json # 本人会话消息 + candidate-scoring.json # 本人评分快照/趋势 + scoring-appeals.json # 本人评分申诉及历史 +``` + +> 以上为当前实现实际包含的 section(`manifest.json` 的 `sections` 字段会列出全部 section 名称);`resumes/` 目录下同时附带用户自己上传的简历文件二进制。 + +### 允许导出(本人) + +- 基本资料:`userId / nickname / avatarUrl / email`(如业务允许)/ 账号创建时间等;**不含 passwordHash**。 +- 学生资料与求职偏好。 +- 简历版本及用户自己上传的简历文件。 +- `COMPANY` 用户自己创建的企业/岗位资料。 +- 自己的投递记录与状态时间线、自己参与的面试、自己收到/创建的 Offer、自己的候选人反馈、岗位收藏、通知、会话与消息、评分快照/趋势/申诉及历史。 + +### 脱敏与禁止导出 + +- 禁止:`passwordHash`、JWT、短信验证码、JWT_SECRET、`deletedPhoneHash`、数据库密码、管理员内部审核备注、内部风控规则、系统异常堆栈、服务器绝对路径、RabbitMQ/Redis/MySQL 连接信息。 +- 不得导出**其他用户**的手机号、邮箱、密码、简历地址。 +- 会话消息:只导出当前用户参与的消息;对方**仅保留 `participantId`(脱敏显示名)**,不导出对方完整资料。 + +--- + +## 七、错误码 + +| 错误码 | 含义 | HTTP | +| --- | --- | --- | +| 1001 | 参数错误(非法 format、page/size 越界) | 400 | +| 2001 | 未认证 | 401 | +| 2002 | 无权限(访问他人任务) | 403 | +| 9401 | 任务不存在 | 404 | +| 9402 | 存在未完成任务 | 409 | +| 9403 | 文件尚未生成 | 409 | +| 9404 | 导出文件已过期 | 409 | +| 9405 | 导出失败 | 409 | +| 9406 | 导出文件不存在 | 409 | +| 9302 | 注销进行中禁止操作 | 409 | +| 9303 | 账号已注销 | 409 | + +错误响应统一格式(`ApiResponse`):`{ "code": <码>, "message": "...", "requestId": "..." }`。 + +--- + +## 八、账号注销兼容 + +- **注销 PENDING 用户**:不允许创建新导出任务;已创建但未执行的任务会被 Worker 阻止并标记 `FAILED`(不再生成含个人信息的新文件)。 +- **注销 COMPLETED 用户**:所有导出接口返回 `409/9303`(创建/查询/详情/下载均被拦截),不能看到或下载新任务。 +- **注销完成清理**:账号注销完成(脱敏)流程会删除该用户的导出文件,将未完成任务标记为 `CANCELLED` 或 `EXPIRED`,写入审计事件;**不删除任何原业务数据库数据**。 + +--- + +## 九、审计 + +复用 `AuditService`,新增 action: + +- `DATA_EXPORT_REQUESTED` / `DATA_EXPORT_PROCESSING` / `DATA_EXPORT_COMPLETED` +- `DATA_EXPORT_DOWNLOADED` / `DATA_EXPORT_FAILED` / `DATA_EXPORT_EXPIRED` + +details 仅记录:`taskId / userId / format / status / sizeBytes` 等;禁止写入文件内容、`storageKey`、手机号、邮箱、密码、Token、绝对路径。 \ No newline at end of file diff --git a/docs/privacy-settings-api.md b/docs/privacy-settings-api.md new file mode 100644 index 0000000..c4415d6 --- /dev/null +++ b/docs/privacy-settings-api.md @@ -0,0 +1,102 @@ +# 隐私设置(Privacy Settings)API 联调说明 + +> 模块:学生隐私设置。用于控制**简历可见性**与**联系方式可见性**,仅 STUDENT 角色可用。 + +## 1. 接口概览 + +| 方法 | 路径 | 角色 | 说明 | +|------|------|------|------| +| GET | `/profile/me/privacy` | STUDENT | 查询当前学生隐私设置;无记录返回默认对象,不写库 | +| PUT | `/profile/me/privacy` | STUDENT | 更新当前学生隐私设置;upsert 确保同一学生至多一行 | + +其余角色:COMPANY / ADMIN → `403 / 2002`;未登录 → `HTTP 401`。 + +身份一律从 JWT / SecurityContext 获取,**不接受** `userId`、`studentUserId`、`candidateId` 等请求参数。 + +## 2. 枚举 `ResumeVisibility` + +| 值 | 含义 | +|----|------| +| `PRIVATE`(默认) | 企业不可见 | +| `APPLIED_COMPANIES` | 仅当学生在该企业存在**有效投递**时企业可见(WITHDRAWN / REJECTED 不算有效投递) | +| `VERIFIED_COMPANIES` | 仅 `verified=true` 的企业可见 | + +## 3. GET /profile/me/privacy + +无 Path / Query / Body 参数。 + +无历史记录时返回**默认对象**(不会向数据库写入任何记录): + +```json +{ + "code": 200, + "message": "ok", + "data": { + "studentUserId": 2, + "resumeVisibility": "PRIVATE", + "contactVisible": false, + "version": 0 + }, + "timestamp": "2026-08-25T10:00:00Z" +} +``` + +有历史记录时额外包含 `id`、`createdAt`、`updatedAt`。 + +| 状态码 | 业务码 | 说明 | +|--------|--------|------| +| 200 | 200 | 成功 | +| 401 | 401 | 未登录 | +| 403 | 2002 | 非 STUDENT 角色 | + +## 4. PUT /profile/me/privacy + +请求体(`ContactVisible`、`resumeVisibility` 必填,`version` 可选): + +```json +{ + "resumeVisibility": "APPLIED_COMPANIES", + "contactVisible": true, + "version": 0 +} +``` + +- 首次调用创建记录,之后更新,始终保证同一学生只有一行。 +- `version` 不传或传 `0` 表示非严格校验;传了且与当前版本不一致 → `409 / 9201`。 + +响应: + +```json +{ + "code": 200, + "message": "ok", + "data": { + "id": 1, + "studentUserId": 2, + "resumeVisibility": "APPLIED_COMPANIES", + "contactVisible": true, + "version": 0, + "createdAt": "2026-08-25T10:00:00", + "updatedAt": "2026-08-25T10:00:00" + }, + "timestamp": "2026-08-25T10:00:00Z" +} +``` + +| 状态码 | 业务码 | 说明 | +|--------|--------|------| +| 200 | 200 | 成功 | +| 400 | 1001 | 缺少必填字段 / 非法枚举 | +| 401 | 401 | 未登录 | +| 403 | 2002 | 非 STUDENT 角色 | +| 409 | 9201 | 乐观锁版本冲突 | + +## 5. 隐私脱敏接入说明 + +现有企业候选人详情 `/company/candidates/{applicationId}` 与候选人列表 `/company/candidates` 已在响应中排除 `resumeUrl` 与联系方式字段,保持原路径、原权限、原白名单不变,且本轮不新增联系方式字段。 + +隐私策略方法 `canCompanyViewResume(studentUserId, companyId, applicationId)` 已随本模块提供,供企业侧按 `ResumeVisibility` 判定简历可见性。 + +## 6. 审计 + +更新隐私设置会写入审计日志 `UPDATE_PRIVACY_SETTINGS`。审计中**不得**写入 `phone / email / password / jwt / resumeUrl` 等敏感值。 \ No newline at end of file diff --git a/docs/report-block-api.md b/docs/report-block-api.md new file mode 100644 index 0000000..a2b4e8e --- /dev/null +++ b/docs/report-block-api.md @@ -0,0 +1,152 @@ +# 举报与屏蔽 API(V33) + +> 版本:V33 实现版 +> 状态:已编码实现,待黑盒验收 +> 适用范围:举报、屏蔽、ADMIN 举报处理;仅允许对 `MessagingService` 增加屏蔽校验。 +> OpenAPI 117 → 125 paths(新增 8 path / 9 operation)。 + +## 1. 定位与边界 + +| 接口 / 模块 | 是否改动 | 关系 | +|---|---|---| +| 投递、Offer、面试、评分、治理、看板、匹配 | 不改 | 屏蔽不影响系统通知、投递、Offer、面试、评分、评价 | +| `MessagingService` | 仅改两方法 | `createOrGetDirectConversation`、`sendMessage` 增加屏蔽校验 | +| `listMessages` / `getConversation` / `listConversations` | 不改 | 历史消息不删除,按现有权限读取 | +| `NotificationService` | 不改语义 | 复用,处理完成后向举报人发送通知,不新增 NotificationType 枚举 | +| AI 自动审核 / 自动封禁 / 自动警告 / 公平性算法 | 不实现 | 本轮明确排除 | + +## 2. 数据库(V33,三张表) + +- `reports`:举报单。`(reporter_user_id, idempotency_key)` 唯一;`active_key` 唯一;`target_type` 仅 USER/MESSAGE;`status` 仅 PENDING/RESOLVED/REJECTED;`message_id` 外键 `ON DELETE SET NULL`;`reporter_user_id`/`reported_user_id` 外键关联 `users`;`@Version` 乐观锁;`evidence_urls` 使用 TEXT 存 JSON 字符串(不采用 MySQL JSON 列)。 +- `block_relations`:`(blocker_user_id, blocked_user_id)` 唯一;禁止自己屏蔽自己;外键 `ON DELETE CASCADE`。 +- `report_decision_history`:`report_id` 外键 `ON DELETE CASCADE`;记录 `from_status → to_status`、操作人、角色、note。 + +约束强校验:`target_type=USER` 时 `target_user_id` 非空且 `message_id` 为空;`target_type=MESSAGE` 反之。 + +## 3. 举报接口 + +``` +POST /reports 提交举报 +GET /reports/me 我的举报列表(status/page/size) +GET /reports/{id} 举报详情(本人或 ADMIN) +``` + +- 身份全取 JWT,不接受 `userId`/`reporterId`/`reportedUserId` 注入。 +- **USER 举报**:客户端传 `targetUserId`,服务端校验存在,`targetUserId ≠ 本人`,`reportedUserId = targetUserId`。 +- **MESSAGE 举报**:仅传 `messageId`,服务端校验存在 + 本人是会话参与者(无权返回 403/2002,不泄露消息是否存在),`reportedUserId` 从消息真实 sender 推导,禁止客户端覆盖。 +- 路由:`/reports/me` 必须在 `/reports/{id}` 之前注册,并有路由回归测试。 + +幂等与防重: + +| 场景 | 行为 | +|---|---| +| 同 reporter + idempotencyKey + 相同请求体 | 返回原举报,200(不新增) | +| 同 reporter + idempotencyKey + 不同请求体 | 409 / 9606 | +| 同 reporter + 同目标 + 同 category + 同 messageId/targetUserId + 已有 PENDING | 409 / 9603 | +| 已 RESOLVED/REJECTED | 不占用 active_key,允许重新提交 | + +evidenceUrls 校验:最多 5 个、每个 ≤500 字符、仅 http/https;后端不主动请求/抓取 URL(防 SSRF)。 + +## 4. 屏蔽接口 + +``` +PUT /blocks/{blockedUserId} 屏蔽 +DELETE /blocks/{blockedUserId} 解除屏蔽(幂等) +GET /blocks/me 我的屏蔽列表(page/size) +``` + +- 屏蔽自己 → 400 / 9608;目标不存在复用以有用户不存在错误码。 +- PUT 已存在返回 200 不新增;DELETE 不存在返回 200 不报错;GET 仅返回本人主动屏蔽的用户。 +- 响应不返回对方手机号/邮箱/密码/简历地址。 +- 屏蔽不影响系统通知、投递、Offer、面试、评分、评价。 + +## 5. MessagingService 屏蔽控制 + +- `createOrGetDirectConversation`:若 A 屏蔽 B 或 B 屏蔽 A 任一方向存在,禁止新建 DIRECT 会话。 +- `sendMessage`:会话内任一方向存在屏蔽,禁止发送新消息。 +- 对外统一 `403 / 2002 / "message cannot be sent"`,不返回“你已被屏蔽”,不泄露哪一方屏蔽了谁。 +- 内部审计记录 `BLOCKED_SEND_PROHIBITED`。 +- 屏蔽期间发送失败的消息直接丢弃,解除屏蔽后不补发。 +- `listMessages`、`getConversation`、`listConversations` 保持现有语义。 + +## 6. ADMIN 举报处理接口 + +``` +GET /admin/reports 举报列表(status/targetType/category/page/size) +GET /admin/reports/{id} 举报详情(含处理历史、脱敏证据 URL、MESSAGE 脱敏内容) +POST /admin/reports/{id}/decision 处理决定 +``` + +- 仅 ADMIN;COMPANY / STUDENT / 未登录分别 403 / 403 / 401。 +- `decision` 仅 RESOLVED / REJECTED;REJECTED 必填 `note`。 +- 仅 PENDING 可处理,重复 → 409 / 9604;非法流转 → 409 / 9605;乐观锁冲突 → 409 / 9605。 +- 处理不自动封禁、不删除消息、不修改投递/面试/评分状态。 +- 权限矩阵: + +| 身份/场景 | HTTP | code | +|---|---|---| +| 未登录 | 401 | 2001 | +| COMPANY / STUDENT | 403 | 2002 | +| ADMIN | 200 | — | +| 非本人非 ADMIN 查详情 | 403 | 2002 | +| 举报不存在 | 404 | 9601 | + +## 7. 错误码 + +| code | 枚举 | HTTP | 说明 | +|---|---|---|---| +| 9601 | REPORT_NOT_FOUND | 404 | 举报不存在 | +| 9602 | CANNOT_REPORT_SELF | 400 | 不能举报自己 | +| 9603 | REPORT_DUPLICATE | 409 | 同目标同分类已有 PENDING | +| 9604 | ALREADY_DECIDED | 409 | 重复决定 | +| 9605 | INVALID_TRANSITION | 409 | 非法流转 / 乐观锁冲突 | +| 9606 | IDEMPOTENCY_CONFLICT | 409 | 幂等键冲突(不同请求体) | +| 9607 | REASON_REQUIRED | 400 | 理由缺失(注意复用 BAD_REQUEST 由校验层触发) | +| 9608 | CANNOT_BLOCK_SELF | 400 | 不能屏蔽自己 | +| 9609 | REPORT_RATE_LIMITED | 429 | 举报频率超限 | +| 9610 | BLOCKED_SEND_PROHIBITED | 内部 | 仅内部审计/日志,HTTP 不直接暴露 | + +复用:BAD_REQUEST(1001)、UNAUTHORIZED(2001)、FORBIDDEN(2002)、用户/消息不存在错误码。 + +## 8. 举报限流 + +- 使用现有 Redis,不新增数据库表;仅限制 `POST /reports`。 +- 每用户默认每小时最多 10 次;key:`report:rate:{userId}:{hour}`。 +- 超限 → 429 / 9609,记录 `REPORT_RATE_LIMITED` 审计。 +- 不影响屏蔽、查询、ADMIN 处理接口;不实现自动封禁。 + +## 9. 通知闭环 + +- 处理完成后向举报人发送一条通知,复用 NotificationService,不新增枚举。 +- `type=REPORT_DECISION`、`businessType=REPORT`、`businessId=reportId`、`idempotencyKey=report-decision-{reportId}`。 +- RESOLVED / REJECTED 均可通知;同一举报最多一条处理结果通知(幂等)。 +- 举报提交时不通知被举报人,不通知被举报人的联系方式。 + +## 10. 审计 + +新增 action:`REPORT_CREATED`、`REPORT_DECIDED`、`BLOCK_CREATED`、`BLOCK_REMOVED`、`BLOCKED_SEND_PROHIBITED`、`REPORT_RATE_LIMITED`。 + +details 仅含:`reportId`、`userId`、`category`、`decision`、`targetType`、`messageId`、`requestId`、`timestamp`。 +禁止写入:phone、email、password、JWT、resumeUrl、消息全文、文件绝对路径、evidence 文件内容、数据库连接信息。 + +## 11. 并发与幂等 + +- 举报:`(reporter_user_id, idempotency_key)` 唯一 + `active_key` 唯一 + 创建事务;并发重复仅一条成功,另一条 9603 或同幂等返回原记录。 +- 屏蔽:唯一约束保证并发 PUT 不产生重复关系;DELETE 幂等;屏蔽检查与发送消息在同一业务事务内。 +- 处理:`@Version` 乐观锁,冲突 409/9605。 + +## 12. 脱敏策略 + +- 响应永不返回 phone、email、passwordHash、resumeUrl、JWT、消息全文、举报人内部 ID(非 ADMIN)。 +- MESSAGE 举报仅返回脱敏 `messagePreview`,不返回会话内其他敏感内容。 +- 对外屏蔽发送统一模糊错误,不泄露屏蔽关系方向。 + +## 13. OpenAPI 变化 + +- 新增 8 path / 9 operation,117 → 125 paths。 +- 新 path:`/reports`、`/reports/me`、`/reports/{id}`、`/blocks/{blockedUserId}`(PUT+DELETE)、`/blocks/me`、`/admin/reports`、`/admin/reports/{id}`、`/admin/reports/{id}/decision`。 +- 声明:bearerAuth、请求/响应 DTO、USER/MESSAGE 枚举、PENDING/RESOLVED/REJECTED 枚举、400/401/403/404/409/429、屏蔽模糊错误、MESSAGE 需 messageId、USER 需 targetUserId。 + +## 14. 测试覆盖要点 + +1. USER 举报;2. MESSAGE 举报;3. MESSAGE 发送者服务端推导;4. 无权访问 messageId;5. 举报自己;6. 不存在 targetUserId;7. evidenceUrls 校验;8. 幂等同请求体;9. 幂等不同请求体;10. PENDING 重复;11. ADMIN 决定;12. 重复决定;13. 决定历史;14. 屏蔽/解除;15. 重复屏蔽/解除;16. 屏蔽后禁止 DIRECT;17. 屏蔽后禁止发送;18. 历史消息可读;19. 失败消息不补发;20. 频率限制;21. 通知幂等;22. 权限矩阵;23. 脱敏;24. 乐观锁并发;25. 业务数据不删除;26. V33 迁移 H2(MODE=MySQL)/MySQL 兼容。 \ No newline at end of file diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index be7ce5b..4625a2a 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -6,13 +6,64 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json' d = json.load(open(p, encoding='utf-8')) print('paths:', len(d['paths'])) -assert len(d['paths']) == 81, f"Expected 81 paths, got {len(d['paths'])}" +assert len(d['paths']) == 125, f"Expected 125 paths, got {len(d['paths'])}" paths = d['paths'] required = [ '/review-weight-templates', '/profile/me/review-weights', + '/profile/me/privacy', '/jobs/{jobId}/personalized-review-score', + '/company/candidates', + '/company/candidates/{applicationId}', + '/profile/me/candidate-scoring', + '/profile/me/candidate-scoring/dimensions/{dimension}', + '/profile/me/candidate-scoring/trend', + '/company/candidates/{applicationId}/scoring', + '/profile/me/candidate-scoring/appeals', + '/profile/me/candidate-scoring/appeals/{appealId}', + '/profile/me/candidate-scoring/appeals/{appealId}/cancel', + '/admin/candidate-scoring/appeals', + '/admin/candidate-scoring/appeals/{appealId}/decision', + '/admin/candidate-scoring/appeals/{appealId}/rollback', + '/admin/candidate-scoring/appeals/{appealId}/history', + '/admin/candidate-scoring/snapshots/{candidateUserId}/{dimension}/{period}/history', + # 企业评分运营看板(7 个接口) + '/company/scoring/dashboard/overview', + '/company/scoring/dashboard/job-rankings', + '/company/scoring/dashboard/job-rankings/{jobId}', + '/company/scoring/dashboard/candidate-rankings', + '/company/scoring/dashboard/candidate-rankings/{applicationId}', + '/company/scoring/dashboard/trend', + '/admin/scoring/dashboard/overview', + # Offer 进阶(5 个 path / 6 个 operation) + '/offers/{id}/counter-offers', + '/offers/{id}/counter-offers/{coId}/accept', + '/offers/{id}/counter-offers/{coId}/reject', + '/offers/compare', + '/offers/statistics', + # 账号注销(2 个 path / 3 个 operation) + '/profile/me/deletion', + '/profile/me/deletion/cancel', + # 个人数据导出(3 个 path / 4 个 operation;详情已覆盖状态查询,无需 /status) + '/profile/me/data-export-tasks', + '/profile/me/data-export-tasks/{taskId}', + '/profile/me/data-export-tasks/{taskId}/download', + # 结构化面试评分(3 个 path / 4 个 operation) + '/interviews/{interviewId}/score', + '/interviews/{interviewId}/score/submit', + '/interviews/{interviewId}/score/history', + # 同岗位竞争力排名(1 个 path / 1 个 operation) + '/jobs/{jobId}/competition-ranking', + # V33 举报与屏蔽(8 个 path / 9 个 operation) + '/reports', + '/reports/me', + '/reports/{id}', + '/blocks/{blockedUserId}', + '/blocks/me', + '/admin/reports', + '/admin/reports/{id}', + '/admin/reports/{id}/decision', ] for path in required: assert path in paths, f"Missing path: {path}" @@ -21,8 +72,67 @@ for path in required: assert 'get' in paths['/review-weight-templates'], "Missing GET /review-weight-templates" assert 'get' in paths['/profile/me/review-weights'], "Missing GET /profile/me/review-weights" assert 'put' in paths['/profile/me/review-weights'], "Missing PUT /profile/me/review-weights" +assert 'get' in paths['/profile/me/privacy'], "Missing GET /profile/me/privacy" +assert 'put' in paths['/profile/me/privacy'], "Missing PUT /profile/me/privacy" assert 'get' in paths['/jobs/{jobId}/personalized-review-score'], "Missing GET /jobs/{jobId}/personalized-review-score" -print(" 4 operations all present") +assert 'get' in paths['/company/candidates'], "Missing GET /company/candidates" +assert 'get' in paths['/company/candidates/{applicationId}'], "Missing GET /company/candidates/{applicationId}" +assert 'get' in paths['/profile/me/candidate-scoring'], "Missing GET /profile/me/candidate-scoring" +assert 'get' in paths['/profile/me/candidate-scoring/dimensions/{dimension}'], "Missing GET /profile/me/candidate-scoring/dimensions/{dimension}" +assert 'get' in paths['/profile/me/candidate-scoring/trend'], "Missing GET /profile/me/candidate-scoring/trend" +assert 'get' in paths['/company/candidates/{applicationId}/scoring'], "Missing GET /company/candidates/{applicationId}/scoring" +# 评分治理(9 个接口落在 8 个路径:POST+GET 共享 /appeals) +assert 'post' in paths['/profile/me/candidate-scoring/appeals'], "Missing POST /profile/me/candidate-scoring/appeals" +assert 'get' in paths['/profile/me/candidate-scoring/appeals'], "Missing GET /profile/me/candidate-scoring/appeals" +assert 'get' in paths['/profile/me/candidate-scoring/appeals/{appealId}'], "Missing GET .../appeals/{appealId}" +assert 'post' in paths['/profile/me/candidate-scoring/appeals/{appealId}/cancel'], "Missing POST .../cancel" +assert 'get' in paths['/admin/candidate-scoring/appeals'], "Missing GET /admin/candidate-scoring/appeals" +assert 'post' in paths['/admin/candidate-scoring/appeals/{appealId}/decision'], "Missing POST .../decision" +assert 'post' in paths['/admin/candidate-scoring/appeals/{appealId}/rollback'], "Missing POST .../rollback" +assert 'get' in paths['/admin/candidate-scoring/appeals/{appealId}/history'], "Missing GET .../history" +assert 'get' in paths['/admin/candidate-scoring/snapshots/{candidateUserId}/{dimension}/{period}/history'], "Missing GET snapshots history" +# 企业评分运营看板(7 个 GET) +assert 'get' in paths['/company/scoring/dashboard/overview'], "Missing GET /company/scoring/dashboard/overview" +assert 'get' in paths['/company/scoring/dashboard/job-rankings'], "Missing GET /company/scoring/dashboard/job-rankings" +assert 'get' in paths['/company/scoring/dashboard/job-rankings/{jobId}'], "Missing GET /company/scoring/dashboard/job-rankings/{jobId}" +assert 'get' in paths['/company/scoring/dashboard/candidate-rankings'], "Missing GET /company/scoring/dashboard/candidate-rankings" +assert 'get' in paths['/company/scoring/dashboard/candidate-rankings/{applicationId}'], "Missing GET /company/scoring/dashboard/candidate-rankings/{applicationId}" +assert 'get' in paths['/company/scoring/dashboard/trend'], "Missing GET /company/scoring/dashboard/trend" +assert 'get' in paths['/admin/scoring/dashboard/overview'], "Missing GET /admin/scoring/dashboard/overview" +# Offer 进阶(6 个 operation / 5 个 path) +assert 'post' in paths['/offers/{id}/counter-offers'], "Missing POST /offers/{id}/counter-offers" +assert 'get' in paths['/offers/{id}/counter-offers'], "Missing GET /offers/{id}/counter-offers" +assert 'patch' in paths['/offers/{id}/counter-offers/{coId}/accept'], "Missing PATCH .../accept" +assert 'patch' in paths['/offers/{id}/counter-offers/{coId}/reject'], "Missing PATCH .../reject" +assert 'get' in paths['/offers/compare'], "Missing GET /offers/compare" +assert 'get' in paths['/offers/statistics'], "Missing GET /offers/statistics" +# 账号注销(3 个 operation) +assert 'get' in paths['/profile/me/deletion'], "Missing GET /profile/me/deletion" +assert 'post' in paths['/profile/me/deletion'], "Missing POST /profile/me/deletion" +assert 'post' in paths['/profile/me/deletion/cancel'], "Missing POST /profile/me/deletion/cancel" +# 个人数据导出(4 个 operation / 3 个 path) +assert 'post' in paths['/profile/me/data-export-tasks'], "Missing POST /profile/me/data-export-tasks" +assert 'get' in paths['/profile/me/data-export-tasks'], "Missing GET /profile/me/data-export-tasks" +assert 'get' in paths['/profile/me/data-export-tasks/{taskId}'], "Missing GET /profile/me/data-export-tasks/{taskId}" +assert 'get' in paths['/profile/me/data-export-tasks/{taskId}/download'], "Missing GET .../download" +print(" Personal data export endpoints present (4 operations / 3 paths)") +# 同岗位竞争力排名(1 个 GET operation) +assert 'get' in paths['/jobs/{jobId}/competition-ranking'], "Missing GET /jobs/{jobId}/competition-ranking" +print(" Competition ranking endpoint present (1 GET operation)") +# V33 举报与屏蔽(8 个 path / 9 个 operation;PUT+DELETE 共享 /blocks/{blockedUserId}) +assert 'post' in paths['/reports'], "Missing POST /reports" +assert 'get' in paths['/reports/me'], "Missing GET /reports/me" +assert 'get' in paths['/reports/{id}'], "Missing GET /reports/{id}" +assert 'put' in paths['/blocks/{blockedUserId}'], "Missing PUT /blocks/{blockedUserId}" +assert 'delete' in paths['/blocks/{blockedUserId}'], "Missing DELETE /blocks/{blockedUserId}" +assert 'get' in paths['/blocks/me'], "Missing GET /blocks/me" +assert 'get' in paths['/admin/reports'], "Missing GET /admin/reports" +assert 'get' in paths['/admin/reports/{id}'], "Missing GET /admin/reports/{id}" +assert 'post' in paths['/admin/reports/{id}/decision'], "Missing POST /admin/reports/{id}/decision" +print(" V33 report/block endpoints present (9 operations / 8 paths)") +print(" Offer advance endpoints all present") +print(" 26 operations all present") +print(" Account deletion endpoints present (3 operations)") data = json.dumps(d) assert '"" : "#/components/schemas/' not in data, "Found empty ref key" diff --git a/mydreamjob-server/docs/backend-development-roadmap.md b/mydreamjob-server/docs/backend-development-roadmap.md new file mode 100644 index 0000000..2ecb4ef --- /dev/null +++ b/mydreamjob-server/docs/backend-development-roadmap.md @@ -0,0 +1,531 @@ +# 职来后端后续开发规划与 Figma 差距修复方案 + +> 文档状态:已确认,作为后续开发排期依据 +> +> 确认日期:2026-08-25 +> +> 适用项目:职来(mydreamjob-server) + +> **正式接口文档目录**:`C:\Users\15642\Desktop\zhilai\docs`(OpenAPI、前端联调、验收、各模块 API 文档的唯一正式目录)。 +> 本文档 `mydreamjob-server\docs\backend-development-roadmap.md` 仅保留为「路线图源文件」,不移动、不删除任何文档。 + +## 1. 文档目的 + +本文档用于统一 Figma UI、产品功能表和后端实现之间的差距,明确后端后续开发顺序。 + +本规划遵循以下原则: + +1. 先修复已经实现但存在安全或契约问题的内容; +2. 再开发完全缺失的新功能; +3. 不破坏已验收接口语义; +4. 不直接修改已有字段类型; +5. 每个模块由开发者完成代码、自动化测试、黑盒验收和文档更新; +6. 未经负责人确认,不开始下一模块。 + +## 2. 当前基线 + +| 项目 | 当前状态 | +|---|---| +| OpenAPI | **125 paths、159 operations** | +| Flyway | **V1–V33** 已存在 | +| 全量测试 | **1255/1255 通过**(本次 `mvn clean verify` 实际数量) | +| 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 | +| 当前结论 | 已达到已实现范围的 Release Candidate;V33 待负责人 MySQL/Docker 黑盒复验 | +| 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 | + +### 2.0 模块状态分类 + +| 类别 | 模块 | +|---|---| +| 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking | +| 已完成、待真实 MySQL/Docker 验收 | **V33 Report/Block**(自动化通过;限流/屏蔽拒发的拒绝型安全审计独立事务已实现。MySQL 黑盒:**待复验**——本次复验被凭据环境阻塞,需负责人提供既有启动配置后复验) | +| 已设计未编码 | 测评系统(Holland/能力测评)、公平性检测、SHAP/规则解释、防刷审计、企业评价管理、企业人才推荐、统一求职进度聚合 | +| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 | + +### 2.1 已确认的产品决策 + +| 事项 | 决策 | +|---|---| +| 同岗位竞争力排名 | 批准开发,排在隐私模块之后 | +| 结构化面试评分 | 批准独立开发,不复用 `candidate_feedback` | +| 隐私设置 | 批准 | +| 账号注销 | 已批准并统一为 **30 天**冷静期(`app.deletion.cooling-days` / `DELETION_COOLING_DAYS`,默认 30)。到期后完成账号 PII 脱敏并保留业务记录与审计轨迹。 | +| 个人数据导出 | 批准,采用异步导出任务 | +| 防刷审计报告 | 仅 ADMIN 可见 | +| 公平性检测 | 纳入后续范围 | +| SHAP/规则解释 | 纳入后续范围,后置实现 | +| 反刷能力 | 纳入后续范围,先做确定性规则型方案 | +| `skills` 字段 | 旧接口继续保持 String,后续可增加兼容字段 `skillsList` | +| Offer 综合比较 | 暂由前端展示,后续再决定是否由后端计算 | +| Figma 重复页面 | 视为同一功能的不同状态或设计稿,不新增重复接口 | + +## 3. 当前已实现功能 + +### 3.1 求职端 + +- 认证与登录; +- 个人资料、求职偏好; +- 岗位搜索、筛选、比较、收藏; +- 岗位评价和评价审核; +- 评价权重设置; +- 投递和投递时间线; +- Offer 基础与进阶功能; +- 面试流程; +- 通知中心、站内信和直聊; +- 双向匹配; +- 简历版本管理和简历导出; +- 求职者评分卡; +- Candidate Scoring Engine; +- 评分申诉、审核和回滚。 + +### 3.2 企业端 + +- 企业资料和企业认证; +- 岗位发布和管理; +- 企业公开主页; +- 企业候选人看板; +- 企业评分运营看板; +- 候选人评分详情; +- Offer 管理; +- 面试管理; +- 评价审核基础能力; +- 双向匹配、通知和直聊。 + +## 4. Figma 与后端覆盖结论 + +### 4.1 已完成或基本完成 + +| Figma 页面/功能 | 当前后端接口 | 状态 | +|---|---|---| +| 首页、岗位搜索、岗位详情 | `/jobs`、`/jobs/search`、`/jobs/filters`、`/jobs/{id}`、`/companies/{id}/jobs` | 已完成 | +| 岗位收藏 | `/jobs/favorites`、`POST/DELETE /jobs/{jobId}/favorite` | 已完成 | +| 岗位对比 | `/jobs/compare` | 已完成 | +| 岗位评价、评价详情 | `/jobs/{jobId}/reviews`、`/summary`、评价 CRUD | 基本完成 | +| 评价权重设置 | `/profile/me/review-weights`、`/review-weight-templates` | 已完成 | +| 通知中心 | `/notifications`、`/unread-count`、`/{id}/read`、`/read-all` | 已完成 | +| 站内信、HR 聊天 | `/conversations`、`/conversations/messages`、`/read` | 已完成 | +| 双向匹配 | 匹配规则、评分、`/applications/{id}/match`、会话接口 | 已完成 | +| 面试安排 | `/interviews*`、`/applications/{id}/interviews` | 已完成 | +| Offer 基础和进阶 | `/offers`、状态、时间线、提醒、还价、比较、统计 | 已完成基础能力 | +| 个人资料、求职偏好 | `/profile/me`、`PUT /profile/me`、`/profile/me/preferences` | 已完成 | +| 简历版本和简历导出 | `/profile/me/resume-versions`、`/resume-export-tasks` | 已完成,但不是个人数据导出 | +| 求职者评分卡 | `/profile/me/candidate-score-card`、`candidate-scoring*` | 已完成 | +| 评分申诉、审核、回滚 | `/profile/me/candidate-scoring/appeals`、`/admin/candidate-scoring/appeals*` | 已完成 | +| 企业候选人看板 | `/company/candidates`、`/company/candidates/{id}` | 已完成 | +| 企业评分看板 | `/company/scoring/dashboard/*` | 已完成 | +| 企业认证 | `/companies/me/verification`、`/admin/companies/verifications*` | 已完成 | + +### 4.2 部分完成或口径不一致 + +| Figma 页面/功能 | 当前实现 | 差距 | +|---|---|---| +| 同岗位竞争力排名 | `GET /jobs/{jobId}/competition-ranking` 已实现 | 已交付;最终口径:WITHDRAWN/REJECTED 退出候选人池(请求 403/2002);无评分但有效投递返回 200 + null 分数;Top5 匿名。不宣称"支持隐私退出" | +| 求职进度 | applications、timeline、offers、interviews 可组合 | 缺统一的求职进度聚合接口 | +| Offer 对比 | `/offers/compare` 返回 Offer 字段 | 综合薪资、福利、发展空间当前由前端展示,后端不计算综合结论 | +| HR 评价详情 | `candidate_feedback` 已实现 | 不是结构化面试评分 | +| 企业评价管理 | 有评价审核和管理员决策 | 缺企业端评价管理、回复和运营动作 | +| 企业人才推荐 | 有候选人列表和评分 | 缺推荐排序、推荐理由和可解释结果 | +| 运营规则配置 | Figma 有页面 | 后端暂无规则配置接口 | +| 防刷审计报告 | 已有评价审核基础能力 | 不是风险等级、风险趋势、风险分布和事件审计报告 | + +### 4.3 完全缺失 + +- 霍兰德职业兴趣测评; +- 四维能力测评; +- 测评结果和测评岗位推荐; +- AI 简历诊断和优化; +- AI 模拟面试和面试题预测; +- 行业技能词云; +- 入职准备清单和公司知识介绍; +- 薪资计算器和薪资参考; +- 公平性检测; +- SHAP/规则解释; +- ADMIN 防刷审计和规则配置; +- 诚信积分和激励。 +- 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)。 + +## 5. 已有代码的优先修复项 + +### 5.1 CandidateFeedback 响应脱敏(第一项) + +当前反馈响应不能向前端暴露以下字段: + +- `companyUserId`; +- `studentUserId`; +- `phone`; +- `email`; +- `passwordHash`; +- `resumeUrl`。 + +建议使用白名单响应 DTO: + +```json +{ + "id": 1, + "applicationId": 31, + "rating": 5, + "content": "沟通良好", + "tags": ["沟通良好"], + "createdAt": "2026-08-25T10:00:00" +} +``` + +修复约束: + +- 不修改请求格式; +- 不修改路径、权限和错误码; +- 不修改 `candidate_feedback` 表; +- 不新增 Flyway; +- 增加企业、学生、越权、未登录和脱敏测试; +- 更新 OpenAPI response schema 和前端联调文档。 + +### 5.2 只需统一文档,不应误改业务语义 + +| 内容 | 处理方式 | +|---|---| +| `skills` 为 String | 保持旧接口兼容,文档说明解析方式,后续再增加 `skillsList` | +| `PENDING`/`INSUFFICIENT_DATA` | 属于评分设计状态,不转成 0 分 | +| `/resume-export-tasks` | 明确为简历导出,不当作个人数据导出 | +| Offer 综合比较 | 暂由前端展示,不新增后端综合评分 | +| Figma 重复页面 | 作为同一接口的不同状态处理 | + +## 6. 后续模块设计与顺序 + +### 阶段 0:已有代码和契约修复 + +#### 模块 0.1:CandidateFeedback 脱敏 + +不新增接口、数据库表或 Flyway。完成后要求专项测试、全量测试、OpenAPI 校验和黑盒脱敏验证全部通过。 + +### 阶段 1:隐私与账户合规 + +#### 模块 1.1:隐私设置 + +接口: + +```text +GET /profile/me/privacy +PUT /profile/me/privacy +``` + +落地现状(V27 Privacy 已实现,真实能力**仅两项**): + +- `resumeVisibility`(简历可见性); +- `contactVisible`(企业是否可联系)。 + +以下为**未来“企业人才推荐/细粒度授权”设计项,尚未实现,不得伪称已提供**: + +- 退出同岗位排名; +- 退出企业人才推荐。 + +> ⚠️ V27 隐私设置接口已验收;后续如需新增“退出同岗位排名 / 退出企业人才推荐”,属于新设计,需随“企业人才推荐/细粒度授权”一并评审,不得在未开发前宣称已支持。 + +建议表和迁移(如未来扩展): + +```text +student_privacy_settings +(V27 已落地,未新增字段) +``` + +#### 模块 1.2:账号注销 + +接口: + +```text +POST /profile/me/deletion +GET /profile/me/deletion +POST /profile/me/deletion/cancel +``` + +规则: + +- 申请后进入 `PENDING`; +- 冷静期统一为 **30 天**(`app.deletion.cooling-days` / `DELETION_COOLING_DAYS`,默认 30);到期后执行账号 PII 脱敏,业务记录与审计轨迹按既有保留策略留存; +- 冷静期支持取消; +- 到期后执行删除、匿名化或保留策略; +- 全流程写入审计日志。 + +#### 模块 1.3:个人数据导出 + +接口: + +```text +POST /profile/me/data-export-tasks +GET /profile/me/data-export-tasks +GET /profile/me/data-export-tasks/{taskId} +GET /profile/me/data-export-tasks/{taskId}/download +``` + +要求: + +- 不复用 `/resume-export-tasks`; +- 使用异步任务; +- 不在 HTTP 请求内同步打包全部数据; +- 导出内容不得包含密码、JWT、验证码等认证敏感信息。 + +建议表和迁移: + +```text +personal_data_export_tasks +V28__personal_data_export.sql +``` + +### 阶段 2:结构化面试评分 + +结构化面试评分独立于 `candidate_feedback`。 + +接口: + +```text +POST /interviews/{interviewId}/score +GET /interviews/{interviewId}/score +PUT /interviews/{interviewId}/score +GET /interviews/{interviewId}/score/history +``` + +权限: + +| 角色 | 权限 | +|---|---| +| COMPANY | 本企业面试可创建、修改、查看 | +| STUDENT | 只能查看本人面试评分 | +| ADMIN | 只读审计 | +| 其他用户 | 403 | + +建议表和迁移: + +```text +interview_scores +interview_score_history +V29__interview_scoring.sql +``` + +### 阶段 3:同岗位竞争力排名 + +接口: + +```text +GET /jobs/{jobId}/competition-ranking +``` + +必须支持: + +- 当前学生在指定岗位的排名; +- cohort 总数; +- 百分位; +- 维度百分位; +- Top 候选人; +- 改进建议; +- 样本不足; +- 无评分候选人; +- 投递状态过滤(WITHDRAWN/REJECTED 退出候选人池,请求返回 403/2002); +- 候选人信息脱敏。 + +必须复用: + +```text +CandidateScoringCalculator +candidate_dimension_scores +applications +jobs +``` + +> ⚠️ V32 已编码并验收。**最终口径**:仅 `SUBMITTED / VIEWED / INTERVIEW / HIRED` 有效投递可进入;`WITHDRAWN / REJECTED` 已退出候选人池(请求 403/2002);无评分但有效投递返回 200 + `rank/overallScore/percentile=null`。**不宣称支持“隐私退出”**;排名不使用 `student_privacy_settings`。 + +初期可以不新增数据库表。 + +### 阶段 4:测评系统 + +开发顺序: + +1. 霍兰德职业兴趣测评; +2. 四维能力测评; +3. 测评答题和进度保存; +4. 测评结果; +5. 测评结果进入 ABILITY/GROWTH; +6. 根据测评结果推荐岗位。 + +建议接口: + +```text +GET /assessments/types +GET /assessments/{type}/questions +POST /assessments/attempts +POST /assessments/attempts/{id}/submit +GET /assessments/attempts/{id}/result +GET /profile/me/assessment-results +``` + +建议表: + +```text +assessment_definitions +assessment_questions +assessment_attempts +assessment_answers +assessment_results +``` + +建议迁移: + +```text +V30__assessment.sql +``` + +### 阶段 5:公平性、SHAP、反刷审计 + +#### 防刷审计 + +仅 ADMIN 可见: + +```text +GET /admin/scoring/risk/overview +GET /admin/scoring/risk/trend +GET /admin/scoring/risk/distribution +GET /admin/scoring/risk/events +GET /admin/scoring/rules +PUT /admin/scoring/rules/{id} +``` + +禁止返回原始手机号、IP、设备指纹、密码、Token 等敏感信息。 + +建议表: + +```text +scoring_risk_events +scoring_rule_configs +scoring_risk_actions +``` + +#### 公平性检测 + +第一阶段做统计检测: + +- 不同群体平均分差异; +- 通过率差异; +- 排名差异; +- 样本不足提示; +- 异常分布提示。 + +#### SHAP/规则解释 + +先实现确定性规则解释,不让前端依赖 AI 模型结果: + +```json +{ + "dimension": "EDUCATION", + "reasons": [ + "学历字段完整", + "学校信息缺失", + "毕业年份有效" + ] +} +``` + +### 阶段 6:企业运营模块 + +开发顺序: + +1. 企业评价管理; +2. 企业评价回复; +3. 运营规则配置; +4. 企业人才推荐; +5. 推荐理由和解释; +6. 企业运营数据增强。 + +### 阶段 7:AI 和增值功能 + +- AI 简历诊断; +- AI 简历优化; +- AI 模拟面试; +- 面试题预测; +- 行业技能词云; +- 入职准备清单; +- 公司知识介绍; +- 简历查重; +- 诚信积分; +- 消息撤回。 + +## 7. 总体优先级 + +| 优先级 | 模块 | +|---|---| +| P0 | CandidateFeedback 脱敏 | +| P0 | 隐私设置 | +| P0 | 账号注销 | +| P0 | 个人数据导出 | +| P0 | 结构化面试评分 | +| P0/P1 | 同岗位竞争力排名 | +| P1 | 霍兰德和四维能力测评 | +| P1 | 统一求职进度 | +| P1 | 企业评价管理 | +| P1 | 企业人才推荐 | +| P1 | 公平性检测 | +| P1 | ADMIN 防刷审计 | +| P2 | SHAP 高级解释 | +| P2 | AI 简历、AI 模拟面试 | +| P2 | 技能词云、入职清单、诚信积分 | + +## 8. 每个模块的统一交付要求 + +每个模块必须交付: + +1. 接口设计; +2. 请求和响应 DTO; +3. Controller; +4. Service; +5. Repository; +6. Flyway 迁移; +7. 错误码; +8. 权限矩阵; +9. 脱敏规则; +10. 幂等设计; +11. 并发控制; +12. Service 单元测试; +13. MockMvc 测试; +14. 持久化测试; +15. Docker/MySQL 黑盒验收; +16. OpenAPI 更新; +17. 前端联调文档; +18. 已验收模块回归测试。 + +## 9. 第一阶段执行指令 + +下一步只执行 CandidateFeedback 脱敏,不开发隐私设置、面试评分或同岗位排名。 + +```text +当前先修复已有 CandidateFeedback 接口的响应脱敏问题,不开发新业务模块。 + +问题: +CandidateFeedback 当前响应包含 companyUserId、studentUserId 等内部身份字段,不符合前端契约和数据脱敏要求。 + +要求: +1. 先检查 CandidateFeedbackController、CandidateFeedbackService、现有 View/DTO、Repository、测试和 OpenAPI。 +2. 新增白名单 CandidateFeedbackResponse DTO。 +3. 保持现有接口路径、请求参数、权限、错误码和数据库结构不变。 +4. 学生和企业响应都不得返回 companyUserId、studentUserId、phone、email、passwordHash、resumeUrl。 +5. 不修改 candidate_feedback 表。 +6. 不新增 Flyway。 +7. 不启动或停止 Docker。 +8. 补充 COMPANY、STUDENT、其他学生、其他企业、未登录和脱敏测试。 +9. 更新 OpenAPI response schema 和 frontend-integration.md。 +10. 执行 CandidateFeedback 专项测试、mvn test、mvn clean verify、python docs/verify_openapi.py。 +11. 完成后停止,不要继续开发隐私设置、面试评分或同岗位排名。 + +最后只输出:修改文件、测试结果、OpenAPI 路径数、脱敏验证结果、是否还有问题、等待下一步指令。 +``` + +## 10. 变更控制 + +未经负责人再次确认,不得: + +- 修改已验收接口的响应语义; +- 将 `skills` 从 String 改成数组; +- 把 CandidateFeedback 当作面试评分; +- 把 `/resume-export-tasks` 当作个人数据导出; +- 把 percentile 当作同岗位排行榜; +- 把评价审核当作完整防刷审计; +- 让 COMPANY 查看防刷审计报告; +- 新增未批准的 Flyway; +- 删除或清理业务数据; +- 启动或停止 Docker。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java index 7e59268..f719074 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java @@ -3,9 +3,12 @@ package com.mydreamjob.application; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.application.dto.CandidateDetailView; +import com.mydreamjob.application.dto.CandidateListResponse; import com.mydreamjob.application.dto.ApplicationCreateRequest; import com.mydreamjob.application.dto.ApplicationStatusRequest; import com.mydreamjob.company.CompanyService; +import com.mydreamjob.auth.UserRepository; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -20,6 +23,7 @@ import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; @Tag(name = "投递", description = "学生投递、撤回、查询;企业查看和审核投递;时间线") @@ -29,6 +33,12 @@ public class ApplicationController { private final ApplicationService applicationService; private final CompanyService companyService; + private final UserRepository userRepository; + + private static final Set ALLOWED_STATUSES = Set.of( + "SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); + private static final Set ALLOWED_SORTS = Set.of("createdAt", "updatedAt"); + private static final Set ALLOWED_ORDERS = Set.of("asc", "desc"); // ── 学生投递 ────────────────────────────────────────────── @@ -137,8 +147,72 @@ public class ApplicationController { return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), getRequestId(request))); } + // ── 企业候选人看板 ──────────────────────────────────────── + + @Operation(summary = "企业候选人列表", description = "跨岗位查询本企业所有候选人投递,支持按岗位、状态、关键词筛选,支持分页和排序") + @GetMapping("/company/candidates") + public ResponseEntity> listCompanyCandidates( + @RequestParam(required = false) Long jobId, + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String sort, + @RequestParam(required = false) String order, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Long userId = resolveUserId(request); + assertCompanyRole(userId); + + // 参数校验 + if (page < 0 || size < 1 || size > 100) + throw new BusinessException(ErrorCode.BAD_REQUEST); + if (jobId != null && jobId <= 0) + throw new BusinessException(ErrorCode.BAD_REQUEST); + if (status != null && !ALLOWED_STATUSES.contains(status)) + throw new BusinessException(ErrorCode.BAD_REQUEST); + if (sort != null && !ALLOWED_SORTS.contains(sort)) + throw new BusinessException(ErrorCode.BAD_REQUEST); + if (order != null && !ALLOWED_ORDERS.contains(order)) + throw new BusinessException(ErrorCode.BAD_REQUEST); + if (keyword != null) { + keyword = keyword.trim(); + if (keyword.isEmpty()) keyword = null; + else if (keyword.length() > 100) + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + var company = companyService.findCompanyByOwnerUserId(userId); + if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); + + CandidateListResponse data = applicationService.listCompanyCandidates( + company.getId(), jobId, status, keyword, sort, order, page, size); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "企业候选人详情", description = "查看投递详情及候选人公开资料,只能查看本企业岗位下的投递") + @GetMapping("/company/candidates/{applicationId}") + public ResponseEntity> getCompanyCandidateDetail( + @PathVariable Long applicationId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + assertCompanyRole(userId); + var company = companyService.findCompanyByOwnerUserId(userId); + if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); + + CandidateDetailView data = applicationService.getCompanyCandidateDetail( + company.getId(), applicationId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + // ── 辅助 ────────────────────────────────────────────────── + private void assertCompanyRole(Long userId) { + var user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if (user.getRoles().stream().noneMatch(r -> r.name().equals("COMPANY"))) + throw new BusinessException(ErrorCode.FORBIDDEN); + } + private Long resolveUserId(HttpServletRequest request) { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth == null || !auth.isAuthenticated()) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java index f1bbfbc..6c10de4 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java @@ -26,4 +26,17 @@ public interface ApplicationRepository extends JpaRepository Page findByJobIdAndStatusOrderByCreatedAtDesc(Long jobId, String status, Pageable pageable); boolean existsByJobIdAndStudentUserId(Long jobId, Long studentUserId); + + /** + * 同岗位竞争排名:判断学生是否存在某岗位的「有效投递」(状态不在排除集合内, + * 即非 WITHDRAWN/REJECTED)。与 {@link #findByJobIdAndStatusNotInOrderByCreatedAtAsc} + * 共用同一排除口径,避免"曾投递但已失效"误放行导致返回 200。 + */ + boolean existsByJobIdAndStudentUserIdAndStatusNotIn(Long jobId, Long studentUserId, + java.util.Collection statuses); + + /** + * 同岗位 rank 用:取某岗位未排除(非 WITHDRAWN/REJECTED)的投递,按投递时间正序。 + */ + List findByJobIdAndStatusNotInOrderByCreatedAtAsc(Long jobId, java.util.Collection statuses); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java index 86d8d5a..fc5fd57 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java @@ -1,5 +1,10 @@ package com.mydreamjob.application; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.application.dto.CandidateDetailView; +import com.mydreamjob.application.dto.CandidateItemView; +import com.mydreamjob.application.dto.CandidateListResponse; import com.mydreamjob.audit.AuditService; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; @@ -10,6 +15,10 @@ import com.mydreamjob.job.JobService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.notification.NotificationService; +import com.mydreamjob.profile.StudentProfile; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.TypedQuery; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -18,9 +27,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; @Slf4j @@ -35,6 +47,15 @@ public class ApplicationService { private final ApplicationStatusHistoryRepository historyRepository; private final NotificationService notificationService; + @PersistenceContext + private EntityManager em; + + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final Set ALLOWED_STATUSES = Set.of( + "SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); + private static final Set ALLOWED_SORTS = Set.of("createdAt", "updatedAt"); + private static final Set ALLOWED_ORDERS = Set.of("asc", "desc"); + // ── 学生端 ──────────────────────────────────────────────── @Transactional @@ -224,4 +245,168 @@ public class ApplicationService { .fromStatus(fromStatus).toStatus(toStatus).reason(reason) .build()); } + + // ── 企业候选人看板 ───────────────────────────────────────── + + /** + * 企业候选人列表:跨岗位查询,JOIN student_profiles + users + * 排序字段通过白名单映射,禁止拼接原始请求参数 + */ + @Transactional(readOnly = true) + public CandidateListResponse listCompanyCandidates(Long companyId, Long jobId, String status, + String keyword, String sort, String order, + int page, int size) { + // 参数校验 + if (page < 0 || size < 1 || size > 100) + throw new BusinessException(ErrorCode.BAD_REQUEST); + if (status != null && !ALLOWED_STATUSES.contains(status)) + throw new BusinessException(ErrorCode.BAD_REQUEST); + if (sort != null && !ALLOWED_SORTS.contains(sort)) + throw new BusinessException(ErrorCode.BAD_REQUEST); + if (order != null && !ALLOWED_ORDERS.contains(order)) + throw new BusinessException(ErrorCode.BAD_REQUEST); + + String escapedKeyword = escapeLike(keyword); + + // 白名单映射排序字段 + String sortField = (sort == null) ? "a.createdAt" : "a." + sort; + String sortOrder = (order == null) ? "DESC" : order.toUpperCase(); + + // 构建 JPQL + String whereClause = "WHERE j.companyId = :companyId"; + if (jobId != null) whereClause += " AND a.jobId = :jobId"; + if (status != null) whereClause += " AND a.status = :status"; + if (escapedKeyword != null) { + whereClause += " AND (sp.realName LIKE CONCAT('%', :keyword, '%')" + + " OR sp.school LIKE CONCAT('%', :keyword, '%')" + + " OR sp.education LIKE CONCAT('%', :keyword, '%'))"; + } + + String selectClause = "SELECT a.id, a.jobId, j.title, a.status, " + + "sp.realName, sp.school, sp.education, sp.graduationYear, " + + "u.avatarUrl, a.createdAt, a.updatedAt"; + String fromClause = "FROM Application a " + + "JOIN Job j ON a.jobId = j.id " + + "LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " + + "LEFT JOIN User u ON a.studentUserId = u.id"; + + String countJpql = "SELECT COUNT(a) " + fromClause + " " + whereClause; + String dataJpql = selectClause + " " + fromClause + " " + whereClause + + " ORDER BY " + sortField + " " + sortOrder + ", a.id " + sortOrder; + + // Count + TypedQuery countQuery = em.createQuery(countJpql, Long.class); + countQuery.setParameter("companyId", companyId); + if (jobId != null) countQuery.setParameter("jobId", jobId); + if (status != null) countQuery.setParameter("status", status); + if (escapedKeyword != null) countQuery.setParameter("keyword", escapedKeyword); + long total = countQuery.getSingleResult(); + + // Data + TypedQuery dataQuery = em.createQuery(dataJpql, Object[].class); + dataQuery.setParameter("companyId", companyId); + if (jobId != null) dataQuery.setParameter("jobId", jobId); + if (status != null) dataQuery.setParameter("status", status); + if (escapedKeyword != null) dataQuery.setParameter("keyword", escapedKeyword); + dataQuery.setFirstResult(page * size); + dataQuery.setMaxResults(size); + + List rows = dataQuery.getResultList(); + List items = rows.stream().map(row -> CandidateItemView.builder() + .applicationId((Long) row[0]) + .jobId((Long) row[1]) + .jobTitle((String) row[2]) + .status((String) row[3]) + .candidateName((String) row[4]) + .candidateSchool((String) row[5]) + .candidateEducation((String) row[6]) + .candidateGraduationYear(row[7] != null ? ((Number) row[7]).shortValue() : null) + .candidateAvatarUrl((String) row[8]) + .createdAt((LocalDateTime) row[9]) + .updatedAt((LocalDateTime) row[10]) + .build()).collect(Collectors.toList()); + + return CandidateListResponse.builder() + .items(items) + .total(total) + .page(page) + .size(size) + .totalPages((int) Math.ceil((double) total / size)) + .build(); + } + + /** + * 企业候选人详情:单次 JOIN 查询,不产生 N+1 + */ + @Transactional(readOnly = true) + public CandidateDetailView getCompanyCandidateDetail(Long companyId, Long applicationId) { + // 单次 JOIN 查询:applications + jobs + student_profiles + users + // 查询结果包含岗位 companyId,数据隔离在 Service 层比较,避免额外的 findById 二次查询 + TypedQuery query = em.createQuery( + "SELECT a, j, sp, u FROM Application a " + + "JOIN Job j ON a.jobId = j.id " + + "LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " + + "LEFT JOIN User u ON a.studentUserId = u.id " + + "WHERE a.id = :applicationId", + Object[].class); + query.setParameter("applicationId", applicationId); + + List results = query.getResultList(); + if (results.isEmpty()) { + // 投递不存在 + throw new BusinessException(ErrorCode.APPLICATION_NOT_FOUND); + } + + Object[] row = results.get(0); + Application app = (Application) row[0]; + com.mydreamjob.job.Job job = (com.mydreamjob.job.Job) row[1]; + StudentProfile sp = (StudentProfile) row[2]; + com.mydreamjob.auth.User u = (com.mydreamjob.auth.User) row[3]; + + // 数据隔离:投递存在但岗位不属于当前企业 → 越权 + if (!job.getCompanyId().equals(companyId)) { + throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); + } + + List skills = Collections.emptyList(); + if (sp != null && sp.getSkillsJson() != null) { + try { + skills = objectMapper.readValue(sp.getSkillsJson(), + new TypeReference>() {}); + } catch (Exception e) { + skills = Collections.emptyList(); + } + } + + return CandidateDetailView.builder() + .applicationId(app.getId()) + .jobId(app.getJobId()) + .jobTitle(job.getTitle()) + .jobCity(job.getCity()) + .jobEmploymentType(job.getEmploymentType()) + .status(app.getStatus()) + .coverLetter(app.getCoverLetter()) + .createdAt(app.getCreatedAt()) + .updatedAt(app.getUpdatedAt()) + .candidateName(sp != null ? sp.getRealName() : null) + .candidateAvatarUrl(u != null ? u.getAvatarUrl() : null) + .candidateBio(sp != null ? sp.getBio() : null) + .candidateLocation(sp != null ? sp.getLocation() : null) + .candidateSchool(sp != null ? sp.getSchool() : null) + .candidateEducation(sp != null ? sp.getEducation() : null) + .candidateGraduationYear(sp != null ? sp.getGraduationYear() : null) + .candidateSkills(skills) + .build(); + } + + /** + * 转义 LIKE 特殊字符,防止用户输入 %、_ 等通配符 + */ + String escapeLike(String keyword) { + if (keyword == null) return null; + return keyword + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_"); + } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java new file mode 100644 index 0000000..02ab7a9 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java @@ -0,0 +1,40 @@ +package com.mydreamjob.application.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 企业候选人详情视图 — 不返回 studentUserId、phone、passwordHash、email、resumeUrl、内部字段 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CandidateDetailView { + + // 投递信息 + private Long applicationId; + private Long jobId; + private String jobTitle; + private String jobCity; + private String jobEmploymentType; + private String status; + private String coverLetter; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + // 候选人信息 + private String candidateName; + private String candidateAvatarUrl; + private String candidateBio; + private String candidateLocation; + private String candidateSchool; + private String candidateEducation; + private Short candidateGraduationYear; + private List candidateSkills; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java new file mode 100644 index 0000000..dee412d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java @@ -0,0 +1,30 @@ +package com.mydreamjob.application.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 企业候选人列表项视图 — 不返回 studentUserId、phone、coverLetter、内部字段 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CandidateItemView { + + private Long applicationId; + private Long jobId; + private String jobTitle; + private String status; + private String candidateName; + private String candidateSchool; + private String candidateEducation; + private Short candidateGraduationYear; + private String candidateAvatarUrl; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateListResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateListResponse.java new file mode 100644 index 0000000..33e9616 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateListResponse.java @@ -0,0 +1,24 @@ +package com.mydreamjob.application.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 企业候选人列表分页响应 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CandidateListResponse { + + private List items; + private long total; + private int page; + private int size; + private int totalPages; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/audit/SecurityAuditService.java b/mydreamjob-server/src/main/java/com/mydreamjob/audit/SecurityAuditService.java new file mode 100644 index 0000000..d7b28c9 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/audit/SecurityAuditService.java @@ -0,0 +1,65 @@ +package com.mydreamjob.audit; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 安全审计服务:以独立事务({@code REQUIRES_NEW})写入拒绝型安全事件, + * 即使外层业务事务因 403/429 等异常回滚,本记录仍能落库,保证可追踪恶意举报与骚扰尝试。 + * + *

仅记录 userId、目标 ID、action、entityType、requestId 与时间等非敏感追踪字段, + * 不记录消息正文、手机号、邮箱、密码、JWT 等隐私。

+ */ +@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]"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java index d13074b..b3a3bd1 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java @@ -3,6 +3,10 @@ package com.mydreamjob.auth; import com.mydreamjob.audit.AuditService; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.deletion.AccountDeletion; +import com.mydreamjob.deletion.AccountDeletionPhoneHasher; +import com.mydreamjob.deletion.AccountDeletionRepository; +import com.mydreamjob.deletion.DeletionStatus; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -22,6 +26,8 @@ public class AuthService { private final JwtTokenProvider jwtTokenProvider; private final SmsVerificationService smsVerificationService; private final AuditService auditService; + private final AccountDeletionRepository accountDeletionRepository; + private final AccountDeletionPhoneHasher accountDeletionPhoneHasher; /** * Mock 登录:验证短信验证码后自动创建/登录用户 @@ -32,6 +38,13 @@ public class AuthService { // 验证验证码(传入 purpose,确保验证码用途匹配) smsVerificationService.verifyCode(phone, code, purpose); + // 原手机号禁止重新注册:已注销账号的手机号墓碑(HMAC 摘要)命中则拒绝, + // 且不得自动创建新用户(不把原手机号明文写入注销表或审计)。 + String phoneHash = accountDeletionPhoneHasher.hash(phone); + if (accountDeletionRepository.findByDeletedPhoneHash(phoneHash).isPresent()) { + throw new BusinessException(ErrorCode.ACCOUNT_DELETED); + } + // 查找或创建用户 Optional userOpt = userRepository.findByPhone(phone); User user; @@ -49,6 +62,12 @@ public class AuthService { user = userOpt.get(); } + // 已注销(COMPLETED)用户禁止登录 + java.util.Optional deletion = accountDeletionRepository.findByUserId(user.getId()); + if (deletion.isPresent() && deletion.get().getStatus() == DeletionStatus.COMPLETED) { + throw new BusinessException(ErrorCode.ACCOUNT_DELETED); + } + // 生成 token String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getPhone(), Map.of("role", user.getRole().name())); String refreshToken = jwtTokenProvider.generateRefreshToken(user.getId(), user.getPhone()); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java index 4fc6e4a..e4de3b8 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java @@ -11,4 +11,7 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); boolean existsByPhone(String phone); boolean existsByEmail(String email); + + /** 统计主角色为给定角色且处于启用状态的用户数(用于最后一个有效 ADMIN 保护)。 */ + long countByRoleAndActiveTrue(User.Role role); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateDimensionScore.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateDimensionScore.java new file mode 100644 index 0000000..5799997 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateDimensionScore.java @@ -0,0 +1,74 @@ +package com.mydreamjob.candidatescore; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 候选人维度评分快照(评分基础设施写入,仅由候选人生成评分引擎 upsert) + *

唯一约束 (student_user_id, dimension, period),同周期覆盖、version 递增。

+ */ +@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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateDimensionScoreRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateDimensionScoreRepository.java new file mode 100644 index 0000000..acdf0b8 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateDimensionScoreRepository.java @@ -0,0 +1,34 @@ +package com.mydreamjob.candidatescore; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CandidateDimensionScoreRepository extends JpaRepository { + + @Query("SELECT s FROM CandidateDimensionScore s WHERE s.studentUserId = :studentUserId AND s.period = :period") + List 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 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 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 findUpToPeriod(@Param("studentUserIds") java.util.Collection studentUserIds, + @Param("asOf") String asOf); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardService.java index a9e39fa..4d35d12 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardService.java @@ -57,7 +57,7 @@ public class CandidateScoreCardService { // 3. 标签聚合(使用 JdbcTemplate 避免 H2 JSON 类型兼容问题) List tagJsons = jdbcTemplate.queryForList( - "SELECT CAST(cf.tags AS VARCHAR) FROM candidate_feedback cf WHERE cf.student_user_id = ?", + "SELECT CAST(cf.tags AS CHAR) FROM candidate_feedback cf WHERE cf.student_user_id = ?", String.class, studentUserId); Map tagCounts = aggregateTags(tagJsons); List tagList = tagCounts.entrySet().stream() diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java new file mode 100644 index 0000000..fc41729 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java @@ -0,0 +1,506 @@ +package com.mydreamjob.candidatescore; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.preference.StudentPreference; +import com.mydreamjob.preference.StudentPreferenceRepository; +import com.mydreamjob.profile.StudentProfile; +import com.mydreamjob.profile.StudentProfileRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 求职者评分确定性计算器(评分治理与评分引擎共享) + *

承载 8 维评分计算、数据采集、快照批量 upsert 与单维度重算;纯确定性规则,无 AI。

+ */ +@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 snapshots) { + if (snapshots == null || snapshots.isEmpty()) return null; + List 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 DIMENSIONS = Set.of( + "EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", + "STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // 内置学校档次名单(v0.1 硬编码,非数据库表) + private static final Set C985 = new HashSet<>(List.of( + "北京大学", "清华大学", "中国人民大学", "北京航空航天大学", "北京理工大学", + "中国农业大学", "北京师范大学", "中央民族大学", "南开大学", "天津大学", + "大连理工大学", "吉林大学", "哈尔滨工业大学", "复旦大学", "同济大学", + "上海交通大学", "华东师范大学", "南京大学", "东南大学", "浙江大学", + "中国科学技术大学", "厦门大学", "山东大学", "中国海洋大学", "武汉大学", + "华中科技大学", "湖南大学", "中南大学", "中山大学", "华南理工大学", + "四川大学", "重庆大学", "电子科技大学", "西安交通大学", "西北工业大学", + "兰州大学", "东北大学", "西北农林科技大学")); + private static final Set 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 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 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 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 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 list = MAPPER.readValue(json, new TypeReference>() {}); + return list.isEmpty(); + } catch (Exception e) { + return true; + } + } + + private int skillCount(StudentProfile profile) { + if (profile == null || isBlank(profile.getSkillsJson())) return 0; + try { + List list = MAPPER.readValue(profile.getSkillsJson(), new TypeReference>() {}); + 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 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 subScores = new ArrayList<>(); + public List reasons = new ArrayList<>(); + public List 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; } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringController.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringController.java new file mode 100644 index 0000000..2e37082 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringController.java @@ -0,0 +1,81 @@ +package com.mydreamjob.candidatescore; + +import com.mydreamjob.candidatescore.dto.CandidateScoringOverviewResponse; +import com.mydreamjob.candidatescore.dto.CandidateScoringTrendResponse; +import com.mydreamjob.candidatescore.dto.CompanyCandidateScoringResponse; +import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse; +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.CompanyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +/** + * 求职者评分体系(Candidate Scoring Engine) + * 学生自查:/profile/me/candidate-scoring*;企业只读:/company/candidates/{applicationId}/scoring + */ +@Tag(name = "求职者评分体系", description = "8 维候选人生成评分、综合分、趋势与企业只读评分卡") +@RestController +@RequiredArgsConstructor +public class CandidateScoringController { + + private final CandidateScoringEngineService scoringEngineService; + private final CompanyService companyService; + + @Operation(summary = "综合评分卡", description = "仅 STUDENT;返回本人 8 维评分、综合分、权重与状态") + @GetMapping("/profile/me/candidate-scoring") + public ResponseEntity> 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> 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> 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> 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java new file mode 100644 index 0000000..23c58a4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java @@ -0,0 +1,171 @@ +package com.mydreamjob.candidatescore; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.candidatescore.dto.*; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; + +/** + * 求职者评分体系(Candidate Scoring Engine)v0.1 + *

公开 4 个接口不变;评分计算委托 {@link CandidateScoringCalculator}(评分治理复用同一计算器)。

+ */ +@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 results = calculator.computeAll(studentUserId); + calculator.upsertSnapshots(studentUserId, results, yearMonth(LocalDate.now())); + return buildOverview(studentUserId, results); + } + + // ── 单维度明细 ─────────────────────────────────────────────── + + @Transactional + public DimensionScoreDetailResponse getDimensionDetail(Long studentUserId, String dimension) { + List 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 snaps = + snapshotRepository.findTrendFrom(studentUserId, startPeriod); + Map> byPeriod = snaps.stream() + .collect(java.util.stream.Collectors.groupingBy(CandidateDimensionScore::getPeriod, + TreeMap::new, java.util.stream.Collectors.toList())); + List items = byPeriod.entrySet().stream().map(e -> { + Map dims = new LinkedHashMap<>(); + List 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 results = calculator.computeAll(app.getStudentUserId()); + calculator.upsertSnapshots(app.getStudentUserId(), results, yearMonth(LocalDate.now())); + List 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 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 results) { + List 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 weights = new LinkedHashMap<>(); + List 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 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 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()); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardController.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardController.java new file mode 100644 index 0000000..79f1ecf --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardController.java @@ -0,0 +1,102 @@ +package com.mydreamjob.candidatescore.dashboard; + +import com.mydreamjob.candidatescore.dashboard.dto.*; +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +/** + * 企业评分运营看板:概览、岗位评分排名、候选人评分排名、评分趋势、全局概览 + */ +@Tag(name = "评分运营看板", description = "企业评分运营看板、评分排名与评分趋势") +@RestController +@RequiredArgsConstructor +public class CompanyScoringDashboardController { + + private final CompanyScoringDashboardService dashboardService; + + @Operation(summary = "企业评分运营概览", description = "仅 COMPANY;只返回本企业数据") + @GetMapping("/company/scoring/dashboard/overview") + public ResponseEntity> 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> 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> 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> 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> 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> 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> 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java new file mode 100644 index 0000000..b45d78d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java @@ -0,0 +1,530 @@ +package com.mydreamjob.candidatescore.dashboard; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.CandidateDimensionScore; +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import com.mydreamjob.candidatescore.dashboard.dto.*; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobService; +import com.mydreamjob.profile.StudentProfile; +import com.mydreamjob.profile.StudentProfileRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 企业评分运营看板:概览、岗位评分排名、候选人评分排名、评分趋势、全局概览。 + *

纯只读聚合;候选人综合分复用 CandidateScoringCalculator.overallScoreFromSnapshots; + * 岗位评价过滤与 JobReviewService.getReviewSummary 口径一致(PUBLISHED + AUTO_APPROVED/APPROVED)。 + * 统计周期统一为 asOfPeriod(当前月份),每候选人读取 <= asOfPeriod 的最新周期。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CompanyScoringDashboardService { + + private static final Set EXCLUDED_APP_STATUS = Set.of("WITHDRAWN", "REJECTED"); + private static final Set 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 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 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 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 jobIds = rows.stream().map(r -> (Long) r[0]).collect(Collectors.toList()); + Map> dimAvg = loadDimensionAverages(jobIds); + + int cohort = rows.size(); + List 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 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 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 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> 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 candidateIds = loadCandidateIds(cid); + Map> candByMonth = new TreeMap<>(); + if (!candidateIds.isEmpty()) { + String in = candidateIds.stream().map(x -> "?").collect(Collectors.joining(",")); + List args = new ArrayList<>(candidateIds); + args.add(startPeriod); args.add(asOf); + Map>> 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> periods : byCandPeriod.values()) { + for (Map.Entry> e : periods.entrySet()) { + BigDecimal overall = calculator.overallScoreFromSnapshots(e.getValue()); + if (overall != null) { + candByMonth.computeIfAbsent(e.getKey(), k -> new ArrayList<>()).add(overall); + } + } + } + } + + List items = new ArrayList<>(); + TreeSet 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> 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 allScores = snapByCandidate.values().stream() + .map(calculator::overallScoreFromSnapshots) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + BigDecimal avgCandidateScore = mean(allScores); + + // 企业维度:管线内去重候选人(排除 WITHDRAWN/REJECTED)→ 综合分 → 企业均值 + // 综合分一律在 Java 复用 CandidateScoringCalculator,不在 SQL 重复实现公式 + Map> 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 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 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 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 loadCandidateScores(Long companyId, String asOfPeriod) { + List 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 candidates = pool.stream().map(r -> (Long) r[0]).collect(Collectors.toList()); + List repApps = pool.stream().map(r -> (Long) r[1]).collect(Collectors.toList()); + + // 代表投递的 jobId(批量) + Map 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> snapByCandidate = new HashMap<>(); + String in = candidates.stream().map(x -> "?").collect(Collectors.joining(",")); + List 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 profiles = new HashMap<>(); + studentProfileRepository.findAllById(candidates) + .forEach(sp -> profiles.put(sp.getUserId(), sp)); + + List out = new ArrayList<>(); + for (Object[] row : pool) { + Long candidate = (Long) row[0]; + Long repApp = (Long) row[1]; + List 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 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 candidates) { + List 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> loadDimensionAverages(List jobIds) { + Map> 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 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 all, int page, int size) { + int from = page * size; + int to = Math.min(from + size, all.size()); + List 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 all, int page, int size) { + int from = page * size; + int to = Math.min(from + size, all.size()); + List 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 dimensionScores = List.of(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/AdminDashboardOverviewResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/AdminDashboardOverviewResponse.java new file mode 100644 index 0000000..b6ae947 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/AdminDashboardOverviewResponse.java @@ -0,0 +1,33 @@ +package com.mydreamjob.candidatescore.dashboard.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 全局运营概览(ADMIN) + */ +@Data +@Builder +public class AdminDashboardOverviewResponse { + + private int totalCompanies; + private int totalJobs; + private int totalCandidates; + @JsonInclude(JsonInclude.Include.ALWAYS) + private BigDecimal avgCandidateScore; + private List topCompanies; + + @Data + @Builder + public static class CompanyScoreItem { + private Long companyId; + private String companyName; + @JsonInclude(JsonInclude.Include.ALWAYS) + private BigDecimal avgCandidateScore; + private int jobCount; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/CandidateRankingDetailResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/CandidateRankingDetailResponse.java new file mode 100644 index 0000000..04d518f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/CandidateRankingDetailResponse.java @@ -0,0 +1,36 @@ +package com.mydreamjob.candidatescore.dashboard.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 单候选人评分排名明细 + */ +@Data +@Builder +public class CandidateRankingDetailResponse { + + private Long applicationId; + private Long jobId; + private String candidateName; + private String candidateSchool; + @JsonInclude(JsonInclude.Include.ALWAYS) + private BigDecimal overallScore; + @JsonInclude(JsonInclude.Include.ALWAYS) + private BigDecimal percentile; + private int cohortSize; + private List dimensionScores; + + @Data + @Builder + public static class DimensionScoreItem { + private String dimension; + @JsonInclude(JsonInclude.Include.ALWAYS) + private BigDecimal score; + private String status; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/CandidateRankingItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/CandidateRankingItem.java new file mode 100644 index 0000000..96dc3e3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/CandidateRankingItem.java @@ -0,0 +1,25 @@ +package com.mydreamjob.candidatescore.dashboard.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 候选人评分排名项(按学生去重) + */ +@Data +@Builder +public class CandidateRankingItem { + + private Long applicationId; // 代表投递 + private Long jobId; + private String candidateName; + private String candidateSchool; + @JsonInclude(JsonInclude.Include.ALWAYS) + private BigDecimal overallScore; // 复用引擎公式,无有效维度 → null + @JsonInclude(JsonInclude.Include.ALWAYS) + private BigDecimal percentile; // 有分候选人中计算,无分 → null + private int sampleCount; // 有分候选人数 +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/CandidateRankingListResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/CandidateRankingListResponse.java new file mode 100644 index 0000000..65539b2 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/CandidateRankingListResponse.java @@ -0,0 +1,20 @@ +package com.mydreamjob.candidatescore.dashboard.dto; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +/** + * 候选人评分排名分页响应 + */ +@Data +@Builder +public class CandidateRankingListResponse { + + private List items; + private long total; + private int page; + private int size; + private int totalPages; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/DashboardOverviewResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/DashboardOverviewResponse.java new file mode 100644 index 0000000..8c3e67b --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/DashboardOverviewResponse.java @@ -0,0 +1,25 @@ +package com.mydreamjob.candidatescore.dashboard.dto; + +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 企业评分运营概览 + */ +@Data +@Builder +public class DashboardOverviewResponse { + + private Long companyId; + private int totalJobs; + private int publishedJobs; + private BigDecimal avgJobScore; // 审核通过评价均值,无 → null + private int totalCandidates; // 管线内去重候选人(含无分) + private BigDecimal avgCandidateScore; // 有分候选人综合分均值,无 → null + private Map scoreDistribution; // 1..5 全键 + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/JobRankingItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/JobRankingItem.java new file mode 100644 index 0000000..1afe050 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/JobRankingItem.java @@ -0,0 +1,27 @@ +package com.mydreamjob.candidatescore.dashboard.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Map; + +/** + * 岗位评分排名项 + */ +@Data +@Builder +public class JobRankingItem { + + private Long jobId; + private String title; + private String city; + private String employmentType; + @JsonInclude(JsonInclude.Include.ALWAYS) + private BigDecimal avgRating; // 审核通过评价均值,无 → null + private int reviewCount; + private int rank; + private int cohortSize; + private Map dimensionAverages; // dimensionCode -> 均值 +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/JobRankingListResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/JobRankingListResponse.java new file mode 100644 index 0000000..1c5e4e3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/JobRankingListResponse.java @@ -0,0 +1,20 @@ +package com.mydreamjob.candidatescore.dashboard.dto; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +/** + * 岗位评分排名分页响应 + */ +@Data +@Builder +public class JobRankingListResponse { + + private List items; + private long total; + private int page; + private int size; + private int totalPages; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/ScoreTrendResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/ScoreTrendResponse.java new file mode 100644 index 0000000..8fac2c7 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/dto/ScoreTrendResponse.java @@ -0,0 +1,31 @@ +package com.mydreamjob.candidatescore.dashboard.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 企业评分趋势(按月) + */ +@Data +@Builder +public class ScoreTrendResponse { + + private Long companyId; + private int months; + private List 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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoringOverviewResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoringOverviewResponse.java new file mode 100644 index 0000000..c5fd232 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoringOverviewResponse.java @@ -0,0 +1,38 @@ +package com.mydreamjob.candidatescore.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * 求职者评分体系 - 综合评分卡(仅 STUDENT 本人) + */ +@Data +@Builder +public class CandidateScoringOverviewResponse { + + private Long candidateId; + private String status; // OK / PARTIAL / INSUFFICIENT_DATA / PENDING + @JsonInclude(JsonInclude.Include.ALWAYS) + private BigDecimal overallScore; + private Map weights; + private List 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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoringTrendResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoringTrendResponse.java new file mode 100644 index 0000000..a9edf7f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoringTrendResponse.java @@ -0,0 +1,31 @@ +package com.mydreamjob.candidatescore.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** + * 求职者评分体系 - 趋势(基于快照,按 period 独立归一化,仅 STUDENT 本人) + */ +@Data +@Builder +public class CandidateScoringTrendResponse { + + private Long candidateId; + private int months; + private List items; + + @Data + @Builder + public static class TrendItem { + private String period; + @JsonInclude(JsonInclude.Include.ALWAYS) + private BigDecimal overallScore; + private String rulesVersion; + private Map dimensions; // 仅含 OK/ESTIMATED 维度 + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CompanyCandidateScoringResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CompanyCandidateScoringResponse.java new file mode 100644 index 0000000..e94c7ac --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CompanyCandidateScoringResponse.java @@ -0,0 +1,36 @@ +package com.mydreamjob.candidatescore.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +/** + * 求职者评分体系 - 企业只读评分卡 + *

仅允许返回 applicationId/overallScore/status/dimensions/generatedAt/rulesVersion; + * 禁止返回 studentUserId/candidateId/phone/email/resumeUrl/sub_scores 细节。

+ */ +@Data +@Builder +public class CompanyCandidateScoringResponse { + + private Long applicationId; + @JsonInclude(JsonInclude.Include.ALWAYS) + private BigDecimal overallScore; + private String status; + private List 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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/DimensionScoreDetailResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/DimensionScoreDetailResponse.java new file mode 100644 index 0000000..22fc03c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/DimensionScoreDetailResponse.java @@ -0,0 +1,44 @@ +package com.mydreamjob.candidatescore.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 求职者评分体系 - 单维度明细(仅 STUDENT 本人) + */ +@Data +@Builder +public class DimensionScoreDetailResponse { + + private String dimension; + @JsonInclude(JsonInclude.Include.ALWAYS) + private BigDecimal score; + private String status; + private List subScores; + private List reasons; + private List 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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppeal.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppeal.java new file mode 100644 index 0000000..984b272 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppeal.java @@ -0,0 +1,101 @@ +package com.mydreamjob.candidatescore.governance; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 候选人评分申诉(评分治理) + *

active_key 唯一保护同一学生、维度、周期的 PENDING/APPROVED 申诉;终态清空 active_key 允许重新提交。

+ */ +@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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistory.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistory.java new file mode 100644 index 0000000..82bebec --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistory.java @@ -0,0 +1,51 @@ +package com.mydreamjob.candidatescore.governance; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 申诉状态流转历史 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "candidate_scoring_appeal_history") +@EntityListeners(AuditingEntityListener.class) +public class CandidateScoringAppealHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "appeal_id", nullable = false) + private Long appealId; + + @Column(name = "from_status", length = 20) + private String fromStatus; + + @Column(name = "to_status", nullable = false, length = 20) + private String toStatus; + + @Column(name = "actor_user_id", nullable = false) + private Long actorUserId; + + /** STUDENT / ADMIN */ + @Column(name = "actor_role", nullable = false, length = 20) + private String actorRole; + + @Column(name = "reason", length = 500) + private String reason; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistoryRepository.java new file mode 100644 index 0000000..570362f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistoryRepository.java @@ -0,0 +1,12 @@ +package com.mydreamjob.candidatescore.governance; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CandidateScoringAppealHistoryRepository extends JpaRepository { + + List findByAppealIdOrderByCreatedAtAsc(Long appealId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealRepository.java new file mode 100644 index 0000000..0b90392 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealRepository.java @@ -0,0 +1,32 @@ +package com.mydreamjob.candidatescore.governance; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public interface CandidateScoringAppealRepository extends JpaRepository { + + Page findByCandidateUserIdOrderByCreatedAtDesc(Long candidateUserId, Pageable pageable); + + Page findByCandidateUserIdAndStatusOrderByCreatedAtDesc( + Long candidateUserId, String status, Pageable pageable); + + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + + Page findByStatusOrderByCreatedAtDesc(String status, Pageable pageable); + + Optional findByCandidateUserIdAndId(Long candidateUserId, Long id); + + Optional findByActiveKey(String activeKey); + + Optional findByCandidateUserIdAndIdempotencyKey(Long candidateUserId, String idempotencyKey); + + List findByCandidateUserIdAndDimensionAndPeriodAndStatusIn( + Long candidateUserId, String dimension, String period, Collection statuses); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceController.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceController.java new file mode 100644 index 0000000..722bc22 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceController.java @@ -0,0 +1,152 @@ +package com.mydreamjob.candidatescore.governance; + +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.candidatescore.governance.dto.AppealDecisionRequest; +import com.mydreamjob.candidatescore.governance.dto.AppealHistoryItem; +import com.mydreamjob.candidatescore.governance.dto.AppealResponse; +import com.mydreamjob.candidatescore.governance.dto.CreateAppealRequest; +import com.mydreamjob.candidatescore.governance.dto.SnapshotHistoryItem; +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 评分治理基础模块:申诉、审核、回滚、审计 + */ +@Tag(name = "评分治理", description = "求职者评分申诉、审核、回滚与审计") +@RestController +@RequiredArgsConstructor +public class CandidateScoringGovernanceController { + + private final CandidateScoringGovernanceService governanceService; + + @Operation(summary = "提交评分申诉", description = "仅 STUDENT 本人;同一学生+维度+周期 PENDING/APPROVED 唯一") + @PostMapping("/profile/me/candidate-scoring/appeals") + public ResponseEntity> 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>> listMyAppeals( + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Long userId = resolveUserId(); + Page 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> 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> 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>> listAllAppeals( + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Long userId = resolveUserId(); + Page 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> 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> 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>> getAppealHistory( + @PathVariable Long appealId, HttpServletRequest request) { + Long userId = resolveUserId(); + List 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>> getSnapshotHistory( + @PathVariable Long candidateUserId, + @PathVariable String dimension, + @PathVariable String period, + HttpServletRequest request) { + Long userId = resolveUserId(); + List data = governanceService.getSnapshotHistory( + userId, candidateUserId, dimension, period); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + // ── 辅助 ──────────────────────────────────────────────────── + + private Map pageView(Page page) { + Map 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java new file mode 100644 index 0000000..d05159e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java @@ -0,0 +1,387 @@ +package com.mydreamjob.candidatescore.governance; + +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.CandidateDimensionScore; +import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import com.mydreamjob.candidatescore.governance.dto.*; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.Year; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * 评分治理基础模块:申诉、审核、回滚、审计。 + *

回滚 = 记录前态 + 按当前数据确定性重算(复用 CandidateScoringCalculator)+ 写新快照版本 + 记录后态。

+ */ +@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 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 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 listMyAppeals(Long studentUserId, String status, int page, int size) { + assertStudentRole(studentUserId); + validatePage(page, size); + PageRequest pr = PageRequest.of(page, size); + Page 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 listAllAppeals(Long adminUserId, String status, int page, int size) { + assertAdminRole(adminUserId); + validatePage(page, size); + PageRequest pr = PageRequest.of(page, size); + Page 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 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 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(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringRollbackLimit.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringRollbackLimit.java new file mode 100644 index 0000000..bf640a6 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringRollbackLimit.java @@ -0,0 +1,44 @@ +package com.mydreamjob.candidatescore.governance; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 每学生每自然年回滚次数限制(并发安全:事务内 SELECT ... FOR UPDATE 锁定本行) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "candidate_scoring_rollback_limits", + uniqueConstraints = @UniqueConstraint(name = "uk_csrl_student_year", + columnNames = {"student_user_id", "rollback_year"})) +@EntityListeners(AuditingEntityListener.class) +public class CandidateScoringRollbackLimit { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "student_user_id", nullable = false) + private Long studentUserId; + + @Column(name = "rollback_year", nullable = false) + private Integer rollbackYear; + + @Column(name = "rollback_count", nullable = false) + @Builder.Default + private Integer rollbackCount = 0; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringRollbackLimitRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringRollbackLimitRepository.java new file mode 100644 index 0000000..6485d81 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringRollbackLimitRepository.java @@ -0,0 +1,16 @@ +package com.mydreamjob.candidatescore.governance; + +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface CandidateScoringRollbackLimitRepository extends JpaRepository { + + /** 悲观锁:同一学生同一年份仅一行,用于串行化并发回滚并稳定统计第 4 次 */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findByStudentUserIdAndRollbackYear(Long studentUserId, Integer rollbackYear); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringSnapshotHistory.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringSnapshotHistory.java new file mode 100644 index 0000000..9c4465a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringSnapshotHistory.java @@ -0,0 +1,66 @@ +package com.mydreamjob.candidatescore.governance; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 快照变更审计(回滚前后完整变化) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "candidate_scoring_snapshot_history") +@EntityListeners(AuditingEntityListener.class) +public class CandidateScoringSnapshotHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "appeal_id", nullable = false) + private Long appealId; + + @Column(name = "candidate_user_id", nullable = false) + private Long candidateUserId; + + @Column(name = "dimension", nullable = false, length = 20) + private String dimension; + + @Column(name = "period", nullable = false, length = 7) + private String period; + + @Column(name = "action", nullable = false, length = 20) + private String action; + + @Column(name = "before_score", precision = 5, scale = 2) + private BigDecimal beforeScore; + + @Column(name = "after_score", precision = 5, scale = 2) + private BigDecimal afterScore; + + @Column(name = "before_version") + private Long beforeVersion; + + @Column(name = "after_version") + private Long afterVersion; + + @Column(name = "before_rules_version", length = 40) + private String beforeRulesVersion; + + @Column(name = "after_rules_version", length = 40) + private String afterRulesVersion; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringSnapshotHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringSnapshotHistoryRepository.java new file mode 100644 index 0000000..20156a2 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringSnapshotHistoryRepository.java @@ -0,0 +1,15 @@ +package com.mydreamjob.candidatescore.governance; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CandidateScoringSnapshotHistoryRepository extends JpaRepository { + + List findByCandidateUserIdAndDimensionAndPeriodOrderByCreatedAtAsc( + Long candidateUserId, String dimension, String period); + + List findByAppealIdOrderByCreatedAtAsc(Long appealId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/AppealDecisionRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/AppealDecisionRequest.java new file mode 100644 index 0000000..2090450 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/AppealDecisionRequest.java @@ -0,0 +1,17 @@ +package com.mydreamjob.candidatescore.governance.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 审核决定请求 + */ +@Data +public class AppealDecisionRequest { + + @NotBlank(message = "decision is required") + private String decision; // APPROVE / REJECT + + /** REJECT 时必填(服务层校验,返回 8604) */ + private String reason; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/AppealHistoryItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/AppealHistoryItem.java new file mode 100644 index 0000000..acefeb1 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/AppealHistoryItem.java @@ -0,0 +1,22 @@ +package com.mydreamjob.candidatescore.governance.dto; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 申诉历史项(学生端不返回 actorUserId,置 null 由 NON_NULL 省略;ADMIN 端返回) + */ +@Data +@Builder +public class AppealHistoryItem { + + private Long id; + private String fromStatus; + private String toStatus; + private Long actorUserId; + private String actorRole; // STUDENT / ADMIN + private String reason; + private LocalDateTime createdAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/AppealResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/AppealResponse.java new file mode 100644 index 0000000..b66113d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/AppealResponse.java @@ -0,0 +1,34 @@ +package com.mydreamjob.candidatescore.governance.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 申诉视图(学生端不返回 reviewerId,置 null 由全局 NON_NULL 序列化省略) + */ +@Data +@Builder +public class AppealResponse { + + private Long appealId; + private Long candidateId; + private String dimension; + private String period; + private String status; + private String reason; + private String decision; + private String decisionReason; + private Long reviewerId; // 学生端不返回:null 时由全局 NON_NULL 序列化省略 + private LocalDateTime reviewedAt; + private LocalDateTime rollbackAt; + @JsonInclude(JsonInclude.Include.ALWAYS) + private BigDecimal scoreAtAppeal; + private Long snapshotVersionAtAppeal; + private String rulesVersion; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/CreateAppealRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/CreateAppealRequest.java new file mode 100644 index 0000000..d2bd7e4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/CreateAppealRequest.java @@ -0,0 +1,25 @@ +package com.mydreamjob.candidatescore.governance.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 提交评分申诉 + */ +@Data +public class CreateAppealRequest { + + @NotBlank(message = "dimension is required") + private String dimension; + + /** yyyy-MM */ + @NotBlank(message = "period is required") + private String period; + + /** 必填由服务层校验(返回 8604),此处不 @NotBlank */ + private String reason; + + @Size(max = 64, message = "idempotencyKey must not exceed 64 characters") + private String idempotencyKey; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/SnapshotHistoryItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/SnapshotHistoryItem.java new file mode 100644 index 0000000..5636cbf --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/dto/SnapshotHistoryItem.java @@ -0,0 +1,28 @@ +package com.mydreamjob.candidatescore.governance.dto; + +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 快照变更审计项(回滚前后完整变化,仅 ADMIN) + */ +@Data +@Builder +public class SnapshotHistoryItem { + + private Long id; + private Long appealId; + private String action; + private String dimension; + private String period; + private BigDecimal beforeScore; + private BigDecimal afterScore; + private Long beforeVersion; + private Long afterVersion; + private String beforeRulesVersion; + private String afterRulesVersion; + private LocalDateTime createdAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index e580040..726014d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java @@ -58,6 +58,14 @@ public enum ErrorCode { OFFER_APPLICATION_NOT_ELIGIBLE(8106, "application is not eligible for offer"), OFFER_REMINDER_DUPLICATE(8107, "duplicate offer reminder"), OFFER_NOT_REMINDABLE(8108, "offer is not eligible for manual reminder"), + OFFER_COUNTER_OFFER_EXISTS(8109, "active counter-offer already exists for this offer"), + OFFER_COUNTER_OFFER_NOT_FOUND(8110, "counter-offer not found"), + OFFER_COUNTER_OFFER_NOT_PENDING(8111, "counter-offer is not in pending status"), + OFFER_NOT_WITHDRAWABLE(8112, "offer is not in a withdrawable status"), + OFFER_COUNTER_OFFER_EXPIRED(8113, "counter-offer has expired"), + OFFER_NOT_NEGOTIABLE(8114, "offer status does not allow counter-offer"), + OFFER_COMPARE_TOO_MANY(8115, "compare supports 2-5 offer ids"), + OFFER_COMPARE_NOT_OWNER(8116, "one or more offers do not belong to the current user"), // 简历版本 82xx RESUME_VERSION_NOT_FOUND(8201, "resume version not found"), @@ -72,6 +80,8 @@ public enum ErrorCode { MATCH_SCORES_LOCKED(8302, "match already succeeded, scores are locked"), MATCH_REQUIRED_FOR_CONVERSATION(8303, "direct conversation between company and candidate requires a matched application"), APPLICATION_NOT_MATCHED(8304, "application is not matched"), + // 站内信 8305 + CONVERSATION_NOT_FOUND(8305, "conversation not found"), // 评价 91xx DUPLICATE_REVIEW(9101, "duplicate review"), @@ -107,9 +117,55 @@ public enum ErrorCode { INVALID_VERIFICATION_STATUS_TRANSITION(8504, "invalid verification status transition"), VERIFICATION_REJECT_REASON_REQUIRED(8505, "reject reason is required"), + // 评分治理 86xx + APPEAL_NOT_FOUND(8601, "appeal not found"), + APPEAL_DUPLICATE(8602, "active appeal already exists for the same dimension and period"), + APPEAL_ALREADY_DECIDED(8603, "appeal already decided"), + APPEAL_REASON_REQUIRED(8604, "appeal reason is required"), + APPEAL_ROLLBACK_LIMIT_EXCEEDED(8605, "annual rollback limit (3) exceeded"), + APPEAL_INVALID_TRANSITION(8606, "invalid appeal status transition"), + APPEAL_IDEMPOTENCY_CONFLICT(8607, "idempotency key conflict: request body differs"), + // 系统 9xxx SERVICE_UNAVAILABLE(9001, "service unavailable"), - RATE_LIMITED(9002, "rate limited"); + RATE_LIMITED(9002, "rate limited"), + + // 隐私设置 9201 + PRIVACY_SETTINGS_CONCURRENT_UPDATE(9201, "privacy settings concurrent update"), + + // 账号注销 93xx + INVALID_DELETION_STATE(9301, "invalid deletion state"), + ACCOUNT_DELETION_PENDING(9302, "account deletion pending, business write is not allowed"), + ACCOUNT_DELETED(9303, "account has been deleted"), + ACCOUNT_DELETION_CONCURRENT_UPDATE(9304, "account deletion concurrent update"), + ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED(9305, "account deletion cancel deadline passed"), + LAST_ADMIN_CANNOT_DELETE(9306, "last active admin cannot be deleted"), + + // 个人数据导出 94xx + DATA_EXPORT_TASK_NOT_FOUND(9401, "data export task not found"), + DATA_EXPORT_DUPLICATE(9402, "an unfinished data export task already exists"), + DATA_EXPORT_NOT_READY(9403, "data export file is not ready yet"), + DATA_EXPORT_EXPIRED(9404, "data export file has expired"), + DATA_EXPORT_FAILED(9405, "data export failed"), + DATA_EXPORT_FILE_NOT_FOUND(9406, "data export file not found"), + + // 结构化面试评分 95xx + INTERVIEW_SCORE_CONCURRENT_UPDATE(9501, "interview score was modified concurrently"), + INTERVIEW_NOT_COMPLETED(9502, "interview is not completed, cannot score"), + INTERVIEW_SCORE_SUBMITTED(9503, "interview score already submitted"), + INTERVIEW_SCORE_NOT_FOUND(9504, "interview score not found"), + + // 举报与屏蔽 96xx + REPORT_NOT_FOUND(9601, "report not found"), + CANNOT_REPORT_SELF(9602, "cannot report yourself"), + REPORT_DUPLICATE(9603, "duplicate pending report"), + REPORT_ALREADY_DECIDED(9604, "report already decided"), + REPORT_INVALID_TRANSITION(9605, "invalid report status transition"), + REPORT_IDEMPOTENCY_CONFLICT(9606, "idempotency key conflict: request body differs"), + REPORT_REASON_REQUIRED(9607, "report reason is required"), + CANNOT_BLOCK_SELF(9608, "cannot block yourself"), + REPORT_RATE_LIMITED(9609, "report rate limited"), + BLOCKED_SEND_PROHIBITED(9610, "message cannot be sent"); private final int code; private final String message; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index d309a73..af6a7c5 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java @@ -68,11 +68,15 @@ public class GlobalExceptionHandler { || errorCode == ErrorCode.JOB_NOT_FOUND.getCode() || errorCode == ErrorCode.APPLICATION_NOT_FOUND.getCode() || errorCode == ErrorCode.OFFER_NOT_FOUND.getCode() + || errorCode == ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND.getCode() || errorCode == ErrorCode.RESUME_VERSION_NOT_FOUND.getCode() || errorCode == ErrorCode.RESUME_EXPORT_TASK_NOT_FOUND.getCode() || errorCode == ErrorCode.USER_NOT_FOUND.getCode() || errorCode == ErrorCode.REVIEW_NOT_FOUND.getCode() - || errorCode == ErrorCode.FEEDBACK_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; + || errorCode == ErrorCode.FEEDBACK_NOT_FOUND.getCode() + || errorCode == ErrorCode.CONVERSATION_NOT_FOUND.getCode() + || errorCode == ErrorCode.APPEAL_NOT_FOUND.getCode() + || errorCode == ErrorCode.DATA_EXPORT_TASK_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; // 冲突 → 409 if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode() || errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode() @@ -86,6 +90,9 @@ public class GlobalExceptionHandler { || errorCode == ErrorCode.OFFER_EXPIRED.getCode() || errorCode == ErrorCode.OFFER_NOT_EDITABLE.getCode() || errorCode == ErrorCode.OFFER_REMINDER_DUPLICATE.getCode() + || errorCode == ErrorCode.OFFER_COUNTER_OFFER_EXISTS.getCode() + || errorCode == ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING.getCode() + || errorCode == ErrorCode.OFFER_COUNTER_OFFER_EXPIRED.getCode() || errorCode == ErrorCode.DEFAULT_RESUME_DELETE_NOT_ALLOWED.getCode() || errorCode == ErrorCode.RESUME_EXPORT_IN_PROGRESS.getCode() || errorCode == ErrorCode.DUPLICATE_DEFAULT_RESUME.getCode() @@ -102,6 +109,9 @@ public class GlobalExceptionHandler { // 越权 → 403(新增:企业-求职者建会话需匹配) if (errorCode == ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION.getCode()) return HttpStatus.FORBIDDEN; + // Offer 比较越权(包含非本人 Offer) → 403 + if (errorCode == ErrorCode.OFFER_COMPARE_NOT_OWNER.getCode()) + return HttpStatus.FORBIDDEN; // 认证不存在 → 404 if (errorCode == ErrorCode.VERIFICATION_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; // 认证冲突 → 409 @@ -112,6 +122,55 @@ public class GlobalExceptionHandler { // 权重并发冲突 → 409 if (errorCode == ErrorCode.REVIEW_WEIGHT_CONCURRENT_UPDATE.getCode()) return HttpStatus.CONFLICT; + // 隐私设置并发冲突 → 409 + if (errorCode == ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE.getCode()) + return HttpStatus.CONFLICT; + // 账号注销状态冲突 → 409(9301-9306) + if (errorCode == ErrorCode.INVALID_DELETION_STATE.getCode() + || errorCode == ErrorCode.ACCOUNT_DELETION_PENDING.getCode() + || errorCode == ErrorCode.ACCOUNT_DELETED.getCode() + || errorCode == ErrorCode.ACCOUNT_DELETION_CONCURRENT_UPDATE.getCode() + || errorCode == ErrorCode.ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED.getCode() + || errorCode == ErrorCode.LAST_ADMIN_CANNOT_DELETE.getCode()) + return HttpStatus.CONFLICT; + // 评分治理冲突 → 409 + if (errorCode == ErrorCode.APPEAL_DUPLICATE.getCode() + || errorCode == ErrorCode.APPEAL_ALREADY_DECIDED.getCode() + || errorCode == ErrorCode.APPEAL_ROLLBACK_LIMIT_EXCEEDED.getCode() + || errorCode == ErrorCode.APPEAL_INVALID_TRANSITION.getCode() + || errorCode == ErrorCode.APPEAL_IDEMPOTENCY_CONFLICT.getCode()) + return HttpStatus.CONFLICT; + // 个人数据导出 → 409(9402-9406,除 9401 已映射 404) + if (errorCode == ErrorCode.DATA_EXPORT_DUPLICATE.getCode() + || errorCode == ErrorCode.DATA_EXPORT_NOT_READY.getCode() + || errorCode == ErrorCode.DATA_EXPORT_EXPIRED.getCode() + || errorCode == ErrorCode.DATA_EXPORT_FAILED.getCode() + || errorCode == ErrorCode.DATA_EXPORT_FILE_NOT_FOUND.getCode()) + return HttpStatus.CONFLICT; + // 结构化面试评分 → 404(9504 评分不存在) + if (errorCode == ErrorCode.INTERVIEW_SCORE_NOT_FOUND.getCode()) + return HttpStatus.NOT_FOUND; + // 结构化面试评分 → 409(9501 并发 / 9502 未完成 / 9503 已提交) + if (errorCode == ErrorCode.INTERVIEW_SCORE_CONCURRENT_UPDATE.getCode() + || errorCode == ErrorCode.INTERVIEW_NOT_COMPLETED.getCode() + || errorCode == ErrorCode.INTERVIEW_SCORE_SUBMITTED.getCode()) + return HttpStatus.CONFLICT; + // 举报与屏蔽:不存在 → 404 + if (errorCode == ErrorCode.REPORT_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; + // 举报与屏蔽:冲突/重复/非法流转 → 409 + if (errorCode == ErrorCode.REPORT_DUPLICATE.getCode() + || errorCode == ErrorCode.REPORT_ALREADY_DECIDED.getCode() + || errorCode == ErrorCode.REPORT_INVALID_TRANSITION.getCode() + || errorCode == ErrorCode.REPORT_IDEMPOTENCY_CONFLICT.getCode()) + return HttpStatus.CONFLICT; + // 举报限流 → 429 + if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode()) + return HttpStatus.TOO_MANY_REQUESTS; + // Offer 进阶契约 → 400(不可撤销、不可还价、比较数量超限) + if (errorCode == ErrorCode.OFFER_NOT_WITHDRAWABLE.getCode() + || errorCode == ErrorCode.OFFER_NOT_NEGOTIABLE.getCode() + || errorCode == ErrorCode.OFFER_COMPARE_TOO_MANY.getCode()) + return HttpStatus.BAD_REQUEST; return HttpStatus.BAD_REQUEST; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java new file mode 100644 index 0000000..84d1a1f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java @@ -0,0 +1,61 @@ +package com.mydreamjob.competition; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.competition.dto.CompetitionRankingResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +/** + * 同岗位竞争力排名接口(V32)。 + * + *

仅 STUDENT(已投递该岗位)可查看;身份只取 JWT,不接受 userId/studentUserId。 + * 未登录 401;COMPANY/ADMIN/未投递 403/2002;岗位不存在或非 PUBLISHED 404/7001。

+ */ +@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> 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"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java new file mode 100644 index 0000000..014f164 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java @@ -0,0 +1,259 @@ +package com.mydreamjob.competition; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.CandidateDimensionScore; +import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.competition.dto.CompetitionRankingResponse; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 同岗位竞争力排名(V32)。 + * + *

为已投递某 PUBLISHED 岗位的学生返回该岗位候选人池内的竞争力排名。 + * 纯只读聚合,无新表、无 Flyway、不写任何业务表;综合分复用 + * {@link CandidateScoringCalculator#overallScoreFromSnapshots}(真实公式), + * 不修改 percentile / 评分引擎 / 企业看板。

+ * + *

排序在 Java 侧完成(不使用 MySQL NULLS LAST),无 N+1:cohort 投递一次批量、 + * 候选人快照一次批量。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CompetitionRankingService { + + private static final int TOP_N = 5; + private static final java.util.Set 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 apps = applicationRepository + .findByJobIdAndStatusNotInOrderByCreatedAtAsc(jobId, EXCLUDED); + + // 2. 批量读取候选人快照(period <= asOf),Java 侧取每人最新周期 —— 无 N+1 + List candidateIds = apps.stream().map(Application::getStudentUserId) + .distinct().collect(Collectors.toList()); + Map> latestByStudent = + loadLatestSnapshots(candidateIds, asOf); + + List rows = new ArrayList<>(); + for (Application app : apps) { + Row r = new Row(); + r.studentUserId = app.getStudentUserId(); + r.createdAt = app.getCreatedAt(); + List 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 meSnaps = me == null + ? List.of() : latestByStudent.getOrDefault(meId, List.of()); + String myPeriod = me == null ? null : me.snapshotPeriod; + + // 5. Top 5 匿名摘要 + List 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> loadLatestSnapshots(List ids, String asOf) { + if (ids.isEmpty()) return Map.of(); + List all = scoreRepository.findUpToPeriod(ids, asOf); + Map>> byStudentPeriod = new HashMap<>(); + for (CandidateDimensionScore s : all) { + byStudentPeriod.computeIfAbsent(s.getStudentUserId(), k -> new HashMap<>()) + .computeIfAbsent(s.getPeriod(), k -> new ArrayList<>()).add(s); + } + Map> result = new HashMap<>(); + for (Map.Entry>> e : byStudentPeriod.entrySet()) { + String latest = Collections.max(e.getValue().keySet()); + result.put(e.getKey(), e.getValue().get(latest)); + } + return result; + } + + private String latestPeriod(List snaps) { + return snaps.stream().map(CandidateDimensionScore::getPeriod) + .max(String::compareTo).orElse(null); + } + + // ── 百分位(严格复用企业看板公式)────────────────────────── + + private BigDecimal computePercentile(List rows, Row me) { + if (me == null || me.overallScore == null) return null; + List 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 buildDimensions(List 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 buildGaps(List snaps) { + List 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 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; } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/competition/dto/CompetitionRankingResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/competition/dto/CompetitionRankingResponse.java new file mode 100644 index 0000000..c7dc1b8 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/competition/dto/CompetitionRankingResponse.java @@ -0,0 +1,78 @@ +package com.mydreamjob.competition.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +/** + * 同岗位竞争力排名响应(V32)。 + * + *

仅 STUDENT(已投递该岗位)可查看;返回本人排名、综合分、维度与缺口, + * 以及匿名化 Top 5 摘要。不返回 candidateId、不返回任何其他候选人身份字段。

+ */ +@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 dimensions; + + /** 基于维度状态的简单缺口建议(数据不足 / 待补充 / 维度未完成) */ + private List gaps; + + /** 匿名化 Top 5 摘要(仅 rank + overallScore,脱敏) */ + private List 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; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/AsyncConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/AsyncConfig.java index ee31e02..feb9853 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/AsyncConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/AsyncConfig.java @@ -28,4 +28,18 @@ public class AsyncConfig { executor.initialize(); return executor; } + + @Bean(name = "dataExportExecutor") + public Executor dataExportExecutor( + @Value("${app.async.data-export.core-pool-size:2}") int corePoolSize, + @Value("${app.async.data-export.max-pool-size:4}") int maxPoolSize, + @Value("${app.async.data-export.queue-capacity:100}") int queueCapacity) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(corePoolSize); + executor.setMaxPoolSize(maxPoolSize); + executor.setQueueCapacity(queueCapacity); + executor.setThreadNamePrefix("data-export-"); + executor.initialize(); + return executor; + } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/OpenApiSecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/OpenApiSecurityConfig.java new file mode 100644 index 0000000..7cfefb0 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/OpenApiSecurityConfig.java @@ -0,0 +1,32 @@ +package com.mydreamjob.config; + +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * OpenAPI / SpringDoc 安全方案配置。 + * + *

定义全局 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")); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index 9eb75b7..f183bb5 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -115,6 +115,10 @@ public class SecurityConfig { // 投递(需登录) .requestMatchers("/applications/**").authenticated() .requestMatchers("/company/applications").authenticated() + // 企业候选人看板(需登录,COMPANY 角色,Service 层校验) + .requestMatchers("/company/candidates/**").authenticated() + // 企业评分运营看板(需登录,COMPANY/ADMIN 角色,Service 层校验) + .requestMatchers("/company/scoring/dashboard/**").authenticated() // 面试(需登录) .requestMatchers("/interviews/**").authenticated() // API 信息入口 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/WebMvcConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/WebMvcConfig.java index fee7cb7..212fc08 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/WebMvcConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/WebMvcConfig.java @@ -1,12 +1,22 @@ package com.mydreamjob.config; +import com.mydreamjob.deletion.DeletionGuardInterceptor; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** - * Web MVC 配置 + * Web MVC 配置:注册账号注销守卫拦截器。 */ @Configuration +@RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { - // 如需添加拦截器、路径样式等,在此配置 + + private final DeletionGuardInterceptor deletionGuardInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(deletionGuardInterceptor).addPathPatterns("/**"); + } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java index 746e2d2..c77d012 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java @@ -5,6 +5,8 @@ import com.mydreamjob.auth.JwtTokenProvider; import com.mydreamjob.auth.LoginRequest; import com.mydreamjob.auth.SmsVerificationService; 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; @@ -75,6 +77,14 @@ public class AuthController { request.getRemoteAddr(), request.getHeader("User-Agent")); return ResponseEntity.ok(ApiResponse.ok(result, getRequestId(request))); + } catch (BusinessException e) { + // 已注销手机号:保留 HTTP 409 / 业务 code=9303,交由全局异常处理器映射。 + // 其余业务异常(如验证码错误、参数校验)维持既有 400/code=400 行为。 + if (e.getErrorCodeInt() == ErrorCode.ACCOUNT_DELETED.getCode()) { + throw e; + } + return ResponseEntity.badRequest().body( + ApiResponse.error(400, e.getMessage(), getRequestId(request))); } catch (Exception e) { return ResponseEntity.badRequest().body( ApiResponse.error(400, e.getMessage(), getRequestId(request))); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/DataExportService.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/DataExportService.java new file mode 100644 index 0000000..fdf25ea --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/DataExportService.java @@ -0,0 +1,215 @@ +package com.mydreamjob.dataexport; + +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.common.api.PageResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.dataexport.dto.CreateDataExportRequest; +import com.mydreamjob.dataexport.dto.DataExportTaskResponse; +import com.mydreamjob.dataexport.storage.PersonalDataExportStorageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.List; +import java.util.Optional; + +/** + * 个人数据导出服务:创建/查询/下载任务,异步触发 worker,兼容账号注销状态。 + * + *

身份由调用方从 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 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 existingByKey = + taskRepository.findFirstByUserIdAndIdempotencyKeyOrderByCreatedAtDesc(userId, key); + if (existingByKey.isPresent()) { + return DataExportTaskResponse.of(existingByKey.get()); + } + } + + // 已完成且未过期 → 直接返回已有任务(不重复创建) + Optional 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 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 result = (status == null || status.isBlank()) + ? taskRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable) + : taskRepository.findByUserIdAndStatusOrderByCreatedAtDesc(userId, status, pageable); + List 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) { + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java new file mode 100644 index 0000000..29e03a9 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java @@ -0,0 +1,482 @@ +package com.mydreamjob.dataexport; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.application.ApplicationStatusHistory; +import com.mydreamjob.application.ApplicationStatusHistoryRepository; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.CandidateDimensionScore; +import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; +import com.mydreamjob.candidatescore.governance.CandidateScoringAppeal; +import com.mydreamjob.candidatescore.governance.CandidateScoringAppealHistory; +import com.mydreamjob.candidatescore.governance.CandidateScoringAppealHistoryRepository; +import com.mydreamjob.candidatescore.governance.CandidateScoringAppealRepository; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.feedback.CandidateFeedback; +import com.mydreamjob.feedback.CandidateFeedbackRepository; +import com.mydreamjob.interview.Interview; +import com.mydreamjob.interview.InterviewRepository; +import com.mydreamjob.interview.InterviewStatusHistory; +import com.mydreamjob.interview.InterviewStatusHistoryRepository; +import com.mydreamjob.job.JobFavorite; +import com.mydreamjob.job.JobFavoriteRepository; +import com.mydreamjob.messaging.Conversation; +import com.mydreamjob.messaging.ConversationMember; +import com.mydreamjob.messaging.ConversationMemberRepository; +import com.mydreamjob.messaging.ConversationRepository; +import com.mydreamjob.messaging.Message; +import com.mydreamjob.messaging.MessageRepository; +import com.mydreamjob.notification.Notification; +import com.mydreamjob.notification.NotificationRepository; +import com.mydreamjob.offer.Offer; +import com.mydreamjob.offer.OfferRepository; +import com.mydreamjob.offer.OfferStatusHistory; +import com.mydreamjob.offer.OfferStatusHistoryRepository; +import com.mydreamjob.preference.StudentPreference; +import com.mydreamjob.preference.StudentPreferenceRepository; +import com.mydreamjob.profile.StudentProfile; +import com.mydreamjob.profile.StudentProfileRepository; +import com.mydreamjob.profile.storage.ResumeStorageService; +import com.mydreamjob.resume.ResumeVersion; +import com.mydreamjob.resume.ResumeVersionRepository; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 个人数据导出收集器:汇总当前用户自己的业务数据,用于打包 ZIP。 + * + *

每个 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 sections = new LinkedHashMap<>(); + private final Map 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 buildProfile(Long userId) { + Map 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 buildPreferences(Long userId) { + Optional opt = studentPreferenceRepository.findByStudentUserId(userId); + if (opt.isEmpty()) { + return Map.of(); + } + StudentPreference sp = opt.get(); + Map 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> list = new ArrayList<>(); + resumeVersionRepository.findByUserIdAndStatusOrderByVersionNoDesc(userId, ResumeVersion.STATUS_ACTIVE) + .forEach(v -> { + Map 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> apps = new ArrayList<>(); + List> 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 applicationView(Application a) { + Map 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 applicationTimelineView(ApplicationStatusHistory h) { + Map 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> ivs = new ArrayList<>(); + List> 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> ivs, List> timelines) { + Map 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 interviewTimelineView(InterviewStatusHistory h) { + Map 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> offers = new ArrayList<>(); + List> 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> offers, List> timelines) { + if (!offersContains(offers, o.getId())) { + offers.add(offerView(o)); + } + offerStatusHistoryRepository.findByOfferIdOrderByCreatedAtAsc(o.getId()) + .forEach(h -> timelines.add(offerTimelineView(h))); + } + + private boolean offersContains(List> offers, Long id) { + return offers.stream().anyMatch(m -> m.get("offerId") != null && m.get("offerId").equals(id)); + } + + private Map offerView(Offer o) { + Map 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 offerTimelineView(OfferStatusHistory h) { + Map 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> 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 feedbackView(CandidateFeedback f) { + Map 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> list = new ArrayList<>(); + jobFavoriteRepository.findByStudentUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, MAX_ROWS)) + .forEach(f -> { + Map 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> list = new ArrayList<>(); + notificationRepository.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, MAX_ROWS)) + .forEach(n -> { + Map 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> convs = new ArrayList<>(); + List> msgs = new ArrayList<>(); + for (ConversationMember member : conversationMemberRepository.findByUserId(userId)) { + conversationRepository.findById(member.getConversationId()).ifPresent(c -> { + Map 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 messageView(Message msg) { + Map 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> scores = new ArrayList<>(); + candidateDimensionScoreRepository.findByStudentUserId(userId).forEach(s -> { + Map 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> appeals = new ArrayList<>(); + candidateScoringAppealRepository.findByCandidateUserIdOrderByCreatedAtDesc( + userId, PageRequest.of(0, MAX_ROWS)) + .forEach(a -> { + Map 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> hist = new ArrayList<>(); + candidateScoringAppealHistoryRepository.findByAppealIdOrderByCreatedAtAsc(a.getId()) + .forEach(h -> { + Map 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; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportBuilder.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportBuilder.java new file mode 100644 index 0000000..95ab178 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportBuilder.java @@ -0,0 +1,97 @@ +package com.mydreamjob.dataexport; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * 个人数据导出 ZIP 打包器。 + * + *

所有 JSON section 写入 {@code personal-data/.json};用户简历文件写入 + * {@code personal-data/resumes/};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 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 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 e : collect.getSections().entrySet()) { + put(zos, ROOT + e.getKey() + ".json", toJsonBytes(e.getKey(), e.getValue())); + } + for (Map.Entry 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) { + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportCleanupService.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportCleanupService.java new file mode 100644 index 0000000..5ebc7d5 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportCleanupService.java @@ -0,0 +1,60 @@ +package com.mydreamjob.dataexport; + +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.dataexport.storage.PersonalDataExportStorageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 账号注销兼容清理:注销完成时删除用户导出文件,将未完成任务标记终态,写入审计。 + * + *

只删除导出产物文件,不删除任何业务数据库数据。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PersonalDataExportCleanupService { + + private final PersonalDataExportTaskRepository taskRepository; + private final PersonalDataExportStorageService storageService; + private final AuditService auditService; + + private static final List 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 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()); + } + } + } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java new file mode 100644 index 0000000..d9d4a79 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java @@ -0,0 +1,106 @@ +package com.mydreamjob.dataexport; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.api.PageResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.dataexport.dto.CreateDataExportRequest; +import com.mydreamjob.dataexport.dto.DataExportTaskResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; + +/** + * 个人数据导出 Controller。 + * + *

身份仅取自 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> 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>> 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> 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 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"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportTask.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportTask.java new file mode 100644 index 0000000..35d8a16 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportTask.java @@ -0,0 +1,114 @@ +package com.mydreamjob.dataexport; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 个人数据导出任务实体。 + * + *

状态机: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; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportTaskRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportTaskRepository.java new file mode 100644 index 0000000..3104280 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportTaskRepository.java @@ -0,0 +1,40 @@ +package com.mydreamjob.dataexport; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.stereotype.Repository; + +import jakarta.persistence.LockModeType; +import java.util.List; +import java.util.Optional; + +@Repository +public interface PersonalDataExportTaskRepository extends JpaRepository { + + /** 用户当前是否存在未完成任务(PENDING/PROCESSING) */ + boolean existsByUserIdAndStatusIn(Long userId, List statuses); + + /** 按幂等键查询用户任务(用于创建幂等重放) */ + Optional findFirstByUserIdAndIdempotencyKeyOrderByCreatedAtDesc(Long userId, String idempotencyKey); + + /** 用户最近一个未完成任务(按创建时间倒序,用于返回原任务) */ + Optional findFirstByUserIdAndStatusInOrderByCreatedAtDesc(Long userId, List statuses); + + /** 用户最近一个已完成任务 */ + Optional findFirstByUserIdAndStatusOrderByCreatedAtDesc(Long userId, String status); + + /** 用户导出任务分页,按创建时间倒序 */ + Page findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + + /** 按状态过滤的分页查询 */ + Page findByUserIdAndStatusOrderByCreatedAtDesc(Long userId, String status, Pageable pageable); + + /** 查询已过期仍未标记的 COMPLETED 任务(文件有效期清理定时任务使用) */ + List findByStatusAndExpiresAtBefore(String status, java.time.LocalDateTime cutoff); + + /** Worker 抢占:领取一个待处理任务(按创建时间升序),乐观锁保证并发只有一个成功 */ + @Lock(LockModeType.OPTIMISTIC) + Optional findFirstByStatusInOrderByCreatedAtAsc(List statuses); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportWorker.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportWorker.java new file mode 100644 index 0000000..290747c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportWorker.java @@ -0,0 +1,153 @@ +package com.mydreamjob.dataexport; + +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.dataexport.storage.PersonalDataExportStorageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 个人数据导出异步工作进程 + 过期清理定时任务。 + * + *

状态流转: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 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); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/dto/CreateDataExportRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/dto/CreateDataExportRequest.java new file mode 100644 index 0000000..6ad0557 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/dto/CreateDataExportRequest.java @@ -0,0 +1,19 @@ +package com.mydreamjob.dataexport.dto; + +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 创建个人数据导出任务请求体。 + * + *

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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/dto/DataExportTaskResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/dto/DataExportTaskResponse.java new file mode 100644 index 0000000..2c304f2 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/dto/DataExportTaskResponse.java @@ -0,0 +1,46 @@ +package com.mydreamjob.dataexport.dto; + +import com.mydreamjob.dataexport.PersonalDataExportTask; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * 个人数据导出任务响应视图。 + * + *

不暴露 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); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/storage/LocalPersonalDataExportStorageService.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/storage/LocalPersonalDataExportStorageService.java new file mode 100644 index 0000000..306bc0a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/storage/LocalPersonalDataExportStorageService.java @@ -0,0 +1,112 @@ +package com.mydreamjob.dataexport.storage; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.stream.Stream; + +/** + * 个人数据导出产物本地存储。 + * + *

目录通过 {@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 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; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/storage/PersonalDataExportStorageService.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/storage/PersonalDataExportStorageService.java new file mode 100644 index 0000000..15ed893 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/storage/PersonalDataExportStorageService.java @@ -0,0 +1,27 @@ +package com.mydreamjob.dataexport.storage; + +/** + * 个人数据导出产物存储:独立目录(与简历原始存储分离),仅保存相对路径,禁止暴露物理路径。 + */ +public interface PersonalDataExportStorageService { + + /** + * 存储导出 ZIP,返回存储后的相对路径(非真实物理路径)。 + */ + String store(Long userId, Long taskId, byte[] content); + + /** + * 根据相对路径读取导出产物字节;文件不存在应抛出业务错误而非 500。 + */ + byte[] read(String storageKey); + + /** + * 删除单个导出产物(文件过期或账号注销时调用)。 + */ + void delete(String storageKey); + + /** + * 删除某用户全部导出产物(账号注销完成时调用)。 + */ + void deleteAllForUser(Long userId); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletion.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletion.java new file mode 100644 index 0000000..d578a16 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletion.java @@ -0,0 +1,77 @@ +package com.mydreamjob.deletion; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 账号注销实体。 + * + *

每个用户至多一行(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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java new file mode 100644 index 0000000..709ee60 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java @@ -0,0 +1,112 @@ +package com.mydreamjob.deletion; + +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.dataexport.PersonalDataExportCleanupService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 注销完成工人(独立事务)。 + * + *

将到期的 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); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java new file mode 100644 index 0000000..271801c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java @@ -0,0 +1,131 @@ +package com.mydreamjob.deletion; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.deletion.dto.AccountDeletionResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "账号注销", description = "当前用户账号注销申请、状态查询与取消(所有登录角色均可操作自己的账号)") +@RestController +@RequestMapping("/profile/me/deletion") +@RequiredArgsConstructor +public class AccountDeletionController { + + private final AccountDeletionService accountDeletionService; + + @Operation( + summary = "申请注销当前账号", + description = "当前登录用户申请注销。NONE → PENDING;PENDING 重复提交返回 200 当前状态(幂等,不重复写审计);" + + "COMPLETED 再次申请返回 409/9301。身份仅从 JWT/SecurityContext 获取,不接受 userId 等身份参数。", + security = @SecurityRequirement(name = "bearerAuth"), + parameters = {}) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "成功;返回当前注销状态(NONE/PENDING);message=ok", + content = @Content( + mediaType = "application/json", + schema = @Schema(ref = "#/components/schemas/ApiResponseAccountDeletionResponse"))), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "未登录、Token 缺失/非法/过期;code=401、message=not authenticated", + content = @Content(mediaType = "application/json")), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "访问被拒绝;code=2002、message=forbidden(当前未使用),保留契约", + content = @Content(mediaType = "application/json")), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "409", + description = "状态冲突:9301(COMPLETED 再申请)、9306(最后一个有效 ADMIN 不能注销)", + content = @Content(mediaType = "application/json")) + }) + @PostMapping + public ResponseEntity> 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> 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> 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"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionPhoneHasher.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionPhoneHasher.java new file mode 100644 index 0000000..d95d072 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionPhoneHasher.java @@ -0,0 +1,43 @@ +package com.mydreamjob.deletion; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** + * 账号注销手机号墓碑哈希器。 + * + *

使用服务端密钥(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); + } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionReadService.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionReadService.java new file mode 100644 index 0000000..fe3d17f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionReadService.java @@ -0,0 +1,29 @@ +package com.mydreamjob.deletion; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * 账号注销只读查询服务。 + * + *

懒转换(如 {@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); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionRepository.java new file mode 100644 index 0000000..309324a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionRepository.java @@ -0,0 +1,28 @@ +package com.mydreamjob.deletion; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * 账号注销仓储。 + */ +@Repository +public interface AccountDeletionRepository extends JpaRepository { + + Optional findByUserId(Long userId); + + /** + * 按原手机号 HMAC 摘要精确命中(用于禁止原手机号重新注册)。 + */ + Optional findByDeletedPhoneHash(String deletedPhoneHash); + + /** + * 查询所有已进入冷静期且已到生效时间的记录(供定时任务扫描)。 + */ + List findByStatusAndEffectiveAtLessThanEqual(DeletionStatus status, LocalDateTime now); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionService.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionService.java new file mode 100644 index 0000000..1617a33 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionService.java @@ -0,0 +1,223 @@ +package com.mydreamjob.deletion; + +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.deletion.dto.AccountDeletionResponse; +import jakarta.persistence.EntityManager; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 账号注销服务(状态机编排)。 + * + *

身份由调用方从 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。 + * + *

懒转换在 {@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(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java new file mode 100644 index 0000000..522f8ae --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java @@ -0,0 +1,95 @@ +package com.mydreamjob.deletion; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.Set; + +/** + * 注销守卫拦截器。 + * + *

基于当前已认证用户的账号注销状态拦截业务请求: + *

    + *
  • COMPLETED:除注销接口与 OPTIONS 外,所有普通业务接口一律返回 409/9303(旧 JWT 即使未过期也被拒绝)
  • + *
  • PENDING:拦截 POST/PUT/PATCH/DELETE 写操作返回 409/9302;GET 与 OPTIONS 放行
  • + *
+ * 注销接口(POST/GET /profile/me/deletion、POST /profile/me/deletion/cancel)始终放行,交由 Controller 决策。 + * 未认证、未注销用户不受影响。 + */ +@Component +@RequiredArgsConstructor +public class DeletionGuardInterceptor implements HandlerInterceptor { + + private static final Set DELETION_PATHS = Set.of( + "/profile/me/deletion", + "/profile/me/deletion/cancel"); + + private static final Set WRITE_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE"); + + private final ObjectProvider 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; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionStatus.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionStatus.java new file mode 100644 index 0000000..03b71c1 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionStatus.java @@ -0,0 +1,19 @@ +package com.mydreamjob.deletion; + +/** + * 账号注销状态机状态。 + * + *
+ * NONE ──申请注销--> PENDING
+ * PENDING ──冷静期内取消--> NONE
+ * PENDING ──30天到期--> COMPLETED(终态)
+ * 
+ */ +public enum DeletionStatus { + /** 正常态:未注销 */ + NONE, + /** 冷静期:可取消,业务写操作被限制 */ + PENDING, + /** 终态:账号已注销并脱敏,登录被阻止 */ + COMPLETED +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/dto/AccountDeletionResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/dto/AccountDeletionResponse.java new file mode 100644 index 0000000..f8e0e52 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/dto/AccountDeletionResponse.java @@ -0,0 +1,48 @@ +package com.mydreamjob.deletion.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.mydreamjob.deletion.DeletionStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 账号注销状态响应 DTO(POST/GET /profile/me/deletion、POST /profile/me/deletion/cancel)。 + * + *

无记录时返回默认对象 {@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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java index e23a9d5..414f1b2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java @@ -2,6 +2,7 @@ package com.mydreamjob.feedback; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.feedback.dto.CandidateFeedbackRequest; +import com.mydreamjob.feedback.dto.CandidateFeedbackResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -23,34 +24,34 @@ public class CandidateFeedbackController { @Operation(summary = "创建候选人反馈", description = "仅岗位所属企业;同一投递只能反馈一次") @PostMapping("/applications/{applicationId}/feedback") - public ResponseEntity>> create( + public ResponseEntity> create( @PathVariable Long applicationId, @Valid @RequestBody CandidateFeedbackRequest req, HttpServletRequest request) { Long userId = resolveUserId(request); - Map view = feedbackService.createFeedback(userId, applicationId, req); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + CandidateFeedbackResponse response = feedbackService.createFeedback(userId, applicationId, req); + return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); } @Operation(summary = "查询候选人反馈", description = "该投递的企业或学生可查看") @GetMapping("/applications/{applicationId}/feedback") - public ResponseEntity>> get( + public ResponseEntity> get( @PathVariable Long applicationId, HttpServletRequest request) { Long userId = resolveUserId(request); - Map view = feedbackService.getFeedback(userId, applicationId); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + CandidateFeedbackResponse response = feedbackService.getFeedback(userId, applicationId); + return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); } @Operation(summary = "修改候选人反馈", description = "仅岗位所属企业") @PutMapping("/applications/{applicationId}/feedback") - public ResponseEntity>> update( + public ResponseEntity> update( @PathVariable Long applicationId, @Valid @RequestBody CandidateFeedbackRequest req, HttpServletRequest request) { Long userId = resolveUserId(request); - Map view = feedbackService.updateFeedback(userId, applicationId, req); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + CandidateFeedbackResponse response = feedbackService.updateFeedback(userId, applicationId, req); + return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); } @Operation(summary = "删除候选人反馈", description = "仅岗位所属企业") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java index 4d2111c..19728c3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java @@ -4,7 +4,10 @@ import com.mydreamjob.application.Application; import com.mydreamjob.application.ApplicationRepository; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.feedback.dto.CandidateFeedbackRequest; +import com.mydreamjob.feedback.dto.CandidateFeedbackResponse; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobService; import com.fasterxml.jackson.core.JsonProcessingException; @@ -25,6 +28,7 @@ public class CandidateFeedbackService { private final CandidateFeedbackRepository feedbackRepository; private final ApplicationRepository applicationRepository; private final JobService jobService; + private final CompanyRepository companyRepository; private static final ObjectMapper mapper = new ObjectMapper(); private Application findApplication(Long appId) { @@ -36,8 +40,14 @@ public class CandidateFeedbackService { return jobService.getJob(app.getJobId()); } - private void assertCompanyOwner(Long companyUserId, Job job) { - if (!job.getCompanyId().equals(companyUserId)) + /** + * 企业归属校验:先按当前登录 userId 解析其拥有的企业, + * 再将企业 id 与 job.companyId 比较(userId 与 companyId 属不同命名空间,不可直接比较)。 + */ + private void assertCompanyOwner(Long userId, Job job) { + Company company = companyRepository.findByOwnerUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN)); + if (!company.getId().equals(job.getCompanyId())) throw new BusinessException(ErrorCode.FORBIDDEN); } @@ -49,48 +59,50 @@ public class CandidateFeedbackService { // ── 创建 ────────────────────────────────────────────────── @Transactional - public Map createFeedback(Long companyUserId, Long applicationId, CandidateFeedbackRequest req) { + public CandidateFeedbackResponse createFeedback(Long userId, Long applicationId, CandidateFeedbackRequest req) { Application app = findApplication(applicationId); Job job = findJob(app); - assertCompanyOwner(companyUserId, job); + assertCompanyOwner(userId, job); if (feedbackRepository.existsByApplicationId(applicationId)) throw new BusinessException(ErrorCode.DUPLICATE_FEEDBACK); CandidateFeedback fb = CandidateFeedback.builder() .applicationId(applicationId) - .companyUserId(companyUserId) + .companyUserId(userId) .studentUserId(app.getStudentUserId()) .rating(req.getRating()).content(req.getContent()) .tags(toJson(req.getTags())) .build(); - return toView(feedbackRepository.save(fb)); + return toResponse(feedbackRepository.save(fb)); } // ── 查询 ────────────────────────────────────────────────── - public Map getFeedback(Long userId, Long applicationId) { + public CandidateFeedbackResponse getFeedback(Long userId, Long applicationId) { Application app = findApplication(applicationId); Job job = findJob(app); - // 企业或学生任一匹配即可查看 - boolean isCompany = job.getCompanyId().equals(userId); + // 企业(按 userId 解析归属企业后比较企业 id)或学生任一匹配即可查看 boolean isStudent = app.getStudentUserId().equals(userId); + boolean isCompany = companyRepository.findByOwnerUserId(userId) + .map(c -> c.getId().equals(job.getCompanyId())) + .orElse(false); if (!isCompany && !isStudent) throw new BusinessException(ErrorCode.FORBIDDEN); CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId) .orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND)); - return toView(fb); + return toResponse(fb); } // ── 修改 ────────────────────────────────────────────────── @Transactional - public Map updateFeedback(Long companyUserId, Long applicationId, CandidateFeedbackRequest req) { + public CandidateFeedbackResponse updateFeedback(Long userId, Long applicationId, CandidateFeedbackRequest req) { Application app = findApplication(applicationId); Job job = findJob(app); - assertCompanyOwner(companyUserId, job); + assertCompanyOwner(userId, job); CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId) .orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND)); @@ -98,16 +110,16 @@ public class CandidateFeedbackService { if (req.getRating() != null) fb.setRating(req.getRating()); if (req.getContent() != null) fb.setContent(req.getContent()); if (req.getTags() != null) fb.setTags(toJson(req.getTags())); - return toView(feedbackRepository.save(fb)); + return toResponse(feedbackRepository.save(fb)); } // ── 删除 ────────────────────────────────────────────────── @Transactional - public void deleteFeedback(Long companyUserId, Long applicationId) { + public void deleteFeedback(Long userId, Long applicationId) { Application app = findApplication(applicationId); Job job = findJob(app); - assertCompanyOwner(companyUserId, job); + assertCompanyOwner(userId, job); CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId) .orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND)); @@ -116,18 +128,17 @@ public class CandidateFeedbackService { // ── 视图 ────────────────────────────────────────────────── - public Map toView(CandidateFeedback fb) { - Map v = new HashMap<>(); - v.put("id", fb.getId()); - v.put("applicationId", fb.getApplicationId()); - v.put("companyUserId", fb.getCompanyUserId()); - v.put("studentUserId", fb.getStudentUserId()); - v.put("rating", fb.getRating()); - v.put("content", fb.getContent()); - v.put("tags", fromJson(fb.getTags())); - v.put("createdAt", fb.getCreatedAt()); - v.put("updatedAt", fb.getUpdatedAt()); - return v; + public CandidateFeedbackResponse toResponse(CandidateFeedback fb) { + if (fb == null) return null; + return CandidateFeedbackResponse.builder() + .id(fb.getId()) + .applicationId(fb.getApplicationId()) + .rating(fb.getRating()) + .content(fb.getContent()) + .tags(fromJson(fb.getTags())) + .createdAt(fb.getCreatedAt()) + .updatedAt(fb.getUpdatedAt()) + .build(); } private String toJson(List list) { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/dto/CandidateFeedbackResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/dto/CandidateFeedbackResponse.java new file mode 100644 index 0000000..607fafe --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/dto/CandidateFeedbackResponse.java @@ -0,0 +1,45 @@ +package com.mydreamjob.feedback.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Candidate feedback response exposed to students and companies. + * Internal company/student user identifiers are intentionally excluded. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "CandidateFeedbackResponse", description = "脱敏后的候选人反馈") +public class CandidateFeedbackResponse { + + @Schema(description = "反馈 ID", example = "1") + private Long id; + + @Schema(description = "投递 ID", example = "31") + private Long applicationId; + + @Schema(description = "评分,范围 1-5", example = "5") + private Integer rating; + + @Schema(description = "反馈内容", example = "沟通良好") + private String content; + + @Schema(description = "反馈标签") + private List tags; + + @Schema(description = "创建时间", example = "2026-08-25T10:00:00") + private LocalDateTime createdAt; + + @Schema(description = "更新时间", example = "2026-08-25T10:00:00") + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewCreateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewCreateRequest.java index b752e19..a4ba029 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewCreateRequest.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interview/dto/InterviewCreateRequest.java @@ -1,6 +1,5 @@ package com.mydreamjob.interview.dto; -import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -10,6 +9,8 @@ import java.time.LocalDateTime; /** * 创建面试请求 + *

ONLINE/OFFLINE 的条件字段校验(meetingUrl/address)由 InterviewService 业务校验负责, + * 以返回对应错误码 8406/8407;此处仅保留普通必填/格式校验。

*/ @Data public class InterviewCreateRequest { @@ -39,16 +40,4 @@ public class InterviewCreateRequest { private String interviewerName; private String notes; - - @AssertTrue(message = "meetingUrl is required for ONLINE interviews") - public boolean isMeetingUrlValid() { - if (!"ONLINE".equals(interviewType)) return true; - return meetingUrl != null && !meetingUrl.isBlank(); - } - - @AssertTrue(message = "address is required for OFFLINE interviews") - public boolean isAddressValid() { - if (!"OFFLINE".equals(interviewType)) return true; - return address != null && !address.isBlank(); - } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScore.java b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScore.java new file mode 100644 index 0000000..5cca191 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScore.java @@ -0,0 +1,106 @@ +package com.mydreamjob.interviewscore; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 结构化面试评分实体(V31)。 + * + *

一场面试一份评分(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; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreController.java b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreController.java new file mode 100644 index 0000000..0dfb616 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreController.java @@ -0,0 +1,89 @@ +package com.mydreamjob.interviewscore; + +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.interviewscore.dto.InterviewScoreHistoryItem; +import com.mydreamjob.interviewscore.dto.InterviewScoreResponse; +import com.mydreamjob.interviewscore.dto.InterviewScoreSaveRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 结构化面试评分接口(V31)。 + * + *

身份只从 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> 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> 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> 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>> 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"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreHistory.java b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreHistory.java new file mode 100644 index 0000000..bffe9aa --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreHistory.java @@ -0,0 +1,56 @@ +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.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +/** + * 面试评分历史快照(V31)。 + * + *

仅记录关键节点:首次创建 NULL→DRAFT、最终提交 DRAFT→SUBMITTED。 + * snapshot_text 由 Jackson 生成的 JSON 字符串存为 TEXT,不使用 JSON 类型。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "interview_score_history") +@EntityListeners(AuditingEntityListener.class) +public class InterviewScoreHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "score_id", nullable = false) + private Long scoreId; + + @Column(name = "interview_id", nullable = false) + private Long interviewId; + + @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; + + @Column(name = "actor_role", nullable = false, length = 20) + private String actorRole; + + @Column(name = "snapshot_text", nullable = false, columnDefinition = "TEXT") + private String snapshotText; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreHistoryRepository.java new file mode 100644 index 0000000..84b62ec --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreHistoryRepository.java @@ -0,0 +1,12 @@ +package com.mydreamjob.interviewscore; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface InterviewScoreHistoryRepository extends JpaRepository { + + List findByScoreIdOrderByCreatedAtAsc(Long scoreId); + + List findByInterviewIdOrderByCreatedAtAsc(Long interviewId); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreRepository.java new file mode 100644 index 0000000..74183cf --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreRepository.java @@ -0,0 +1,10 @@ +package com.mydreamjob.interviewscore; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface InterviewScoreRepository extends JpaRepository { + + Optional findByInterviewId(Long interviewId); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreService.java b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreService.java new file mode 100644 index 0000000..0531f72 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreService.java @@ -0,0 +1,400 @@ +package com.mydreamjob.interviewscore; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.interview.Interview; +import com.mydreamjob.interview.InterviewRepository; +import com.mydreamjob.interviewscore.dto.InterviewScoreHistoryItem; +import com.mydreamjob.interviewscore.dto.InterviewScoreResponse; +import com.mydreamjob.interviewscore.dto.InterviewScoreSaveRequest; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobService; +import com.mydreamjob.notification.NotificationService; +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 结构化面试评分服务(V31)。 + * + *

归属校验严格按照 Interview 实际结构:Interview 无 job_id,job 经 application.jobId 推导。 + * 仅 COMPLETED 面试可评分;状态机 DRAFT → SUBMITTED。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class InterviewScoreService { + + public static final String NOTIFICATION_TYPE_SCORE_SUBMITTED = "INTERVIEW_SCORE_SUBMITTED"; + public static final String AUDIT_ACTION_SAVED = "INTERVIEW_SCORE_SAVED"; + public static final String AUDIT_ACTION_SUBMITTED = "INTERVIEW_SCORE_SUBMITTED"; + + private final InterviewScoreRepository scoreRepository; + private final InterviewScoreHistoryRepository historyRepository; + private final InterviewRepository interviewRepository; + private final ApplicationRepository applicationRepository; + private final JobService jobService; + private final CompanyRepository companyRepository; + private final UserRepository userRepository; + private final NotificationService notificationService; + private final AuditService auditService; + private final ObjectMapper objectMapper; + + @PersistenceContext + private EntityManager entityManager; + + // ── 保存草稿(PUT,upsert)───────────────────────────────── + + @Transactional + public InterviewScoreResponse saveDraft(Long operatorUserId, Long interviewId, + InterviewScoreSaveRequest req, AuditContext ctx) { + Company company = requireScoringCompany(interviewId, operatorUserId); + Interview interview = requireInterview(interviewId); + requireCompleted(interview); + + validateRecommendation(req.getRecommendation()); + + InterviewScore score = scoreRepository.findByInterviewId(interviewId).orElse(null); + if (score != null && InterviewScore.STATUS_SUBMITTED.equals(score.getStatus())) { + throw new BusinessException(ErrorCode.INTERVIEW_SCORE_SUBMITTED); + } + + boolean create = (score == null); + InterviewScore target = score; + if (create) { + target = InterviewScore.builder() + .interviewId(interviewId) + .applicationId(interview.getApplicationId()) + .companyUserId(interview.getCompanyUserId()) + .studentUserId(interview.getStudentUserId()) + .status(InterviewScore.STATUS_DRAFT) + .build(); + } + + target.setTechnicalScore(req.getTechnicalScore()); + target.setCommunicationScore(req.getCommunicationScore()); + target.setProblemSolvingScore(req.getProblemSolvingScore()); + target.setRoleFitScore(req.getRoleFitScore()); + target.setProfessionalismScore(req.getProfessionalismScore()); + target.setRecommendation(req.getRecommendation()); + target.setComment(trimToNull(req.getComment())); + target.setOverallScore(computeOverall(target)); + + try { + InterviewScore saved = scoreRepository.saveAndFlush(target); + if (create) { + recordHistory(saved.getId(), interviewId, null, InterviewScore.STATUS_DRAFT, + operatorUserId, User.Role.COMPANY, saved); + } + auditService.log(operatorUserId, AUDIT_ACTION_SAVED, "INTERVIEW_SCORE", saved.getId(), + "scoreId=" + saved.getId() + "; interviewId=" + saved.getInterviewId() + + "; userId=" + operatorUserId + "; status=" + saved.getStatus() + + "; version=" + saved.getVersion() + + "; overallScore=" + (saved.getOverallScore() == null ? 0 : saved.getOverallScore()) + + "; dimensionKey=technicalScore|communicationScore|problemSolvingScore|roleFitScore|professionalismScore", + ctx); + return toResponse(saved); + } catch (OptimisticLockingFailureException | DataIntegrityViolationException e) { + entityManager.clear(); + InterviewScore fresh = scoreRepository.findByInterviewId(interviewId).orElse(null); + if (fresh != null && InterviewScore.STATUS_SUBMITTED.equals(fresh.getStatus())) { + throw new BusinessException(ErrorCode.INTERVIEW_SCORE_SUBMITTED); + } + if (create && fresh != null) { + return toResponse(fresh); + } + throw new BusinessException(ErrorCode.INTERVIEW_SCORE_CONCURRENT_UPDATE); + } + } + + // ── 查看当前评分 ─────────────────────────────────────────── + + @Transactional(readOnly = true) + public InterviewScoreResponse getScore(Long userId, Long interviewId) { + Interview interview = requireInterview(interviewId); + User user = loadUser(userId); + + if (hasRole(user, User.Role.COMPANY)) { + requireScoringCompany(interviewId, userId); + } else if (hasRole(user, User.Role.STUDENT)) { + if (!interview.getStudentUserId().equals(userId)) { + throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); + } + } else { + throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); + } + + InterviewScore score = scoreRepository.findByInterviewId(interviewId).orElse(null); + if (score == null) { + throw new BusinessException(ErrorCode.INTERVIEW_SCORE_NOT_FOUND); + } + // 仅纯 STUDENT(无 COMPANY 角色)不可见草稿;兼具 COMPANY+STUDENT 的企业 owner 允许查看 + if (hasRole(user, User.Role.STUDENT) && !hasRole(user, User.Role.COMPANY) + && InterviewScore.STATUS_DRAFT.equals(score.getStatus())) { + throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); + } + return toResponse(score); + } + + // ── 提交评分(POST /submit)────────────────────────────── + + @Transactional + public InterviewScoreResponse submitScore(Long operatorUserId, Long interviewId, AuditContext ctx) { + Company company = requireScoringCompany(interviewId, operatorUserId); + Interview interview = requireInterview(interviewId); + requireCompleted(interview); + + InterviewScore score = scoreRepository.findByInterviewId(interviewId) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERVIEW_SCORE_NOT_FOUND)); + + if (InterviewScore.STATUS_SUBMITTED.equals(score.getStatus())) { + // 幂等:重复提交返回现有评分,不新增历史、不重复通知 + return toResponse(score); + } + if (!score.allDimensionsPresent()) { + throw new BusinessException(ErrorCode.BAD_REQUEST, + "all five dimensions are required before submitting"); + } + + String fromStatus = score.getStatus(); + score.setStatus(InterviewScore.STATUS_SUBMITTED); + score.setOverallScore(computeOverall(score)); + score.setSubmittedAt(LocalDateTime.now()); + try { + InterviewScore saved = scoreRepository.saveAndFlush(score); + recordHistory(saved.getId(), interviewId, fromStatus, InterviewScore.STATUS_SUBMITTED, + operatorUserId, User.Role.COMPANY, saved); + auditService.log(operatorUserId, AUDIT_ACTION_SUBMITTED, "INTERVIEW_SCORE", saved.getId(), + "scoreId=" + saved.getId() + "; interviewId=" + saved.getInterviewId() + + "; userId=" + operatorUserId + "; status=" + saved.getStatus() + + "; version=" + saved.getVersion() + + "; overallScore=" + (saved.getOverallScore() == null ? 0 : saved.getOverallScore()) + + "; dimensionKey=technicalScore|communicationScore|problemSolvingScore|roleFitScore|professionalismScore", + ctx); + notifyStudentScoreSubmitted(saved); + return toResponse(saved); + } catch (OptimisticLockingFailureException e) { + entityManager.clear(); + InterviewScore fresh = scoreRepository.findByInterviewId(interviewId).orElse(null); + if (fresh != null && InterviewScore.STATUS_SUBMITTED.equals(fresh.getStatus())) { + return toResponse(fresh); + } + throw new BusinessException(ErrorCode.INTERVIEW_SCORE_CONCURRENT_UPDATE); + } + } + + // ── 查看历史 ────────────────────────────────────────────── + + @Transactional(readOnly = true) + public List getHistory(Long userId, Long interviewId) { + Interview interview = requireInterview(interviewId); + User user = loadUser(userId); + + if (hasRole(user, User.Role.COMPANY)) { + requireScoringCompany(interviewId, userId); + } else if (hasRole(user, User.Role.STUDENT)) { + if (!interview.getStudentUserId().equals(userId)) { + throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); + } + InterviewScore score = scoreRepository.findByInterviewId(interviewId).orElse(null); + if (score != null && InterviewScore.STATUS_DRAFT.equals(score.getStatus())) { + throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); + } + } else { + throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); + } + + return historyRepository.findByInterviewIdOrderByCreatedAtAsc(interviewId).stream() + .map(this::toHistoryItem) + .collect(Collectors.toList()); + } + + // ── 归属校验链(严格按 Interview 实际结构)──────────────── + + private Company requireScoringCompany(Long interviewId, Long userId) { + User user = loadUser(userId); + if (!hasRole(user, User.Role.COMPANY)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + Interview interview = requireInterview(interviewId); + Company company = companyRepository.findByOwnerUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + // 1. 当前企业的 owner 必须是该面试的创建企业 + if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) { + throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); + } + // 2. 经 application.jobId 推导 job,校验 job.companyId == company.id + Application app = applicationRepository.findById(interview.getApplicationId()) + .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); + Job job = jobService.getJob(app.getJobId()); + if (!company.getId().equals(job.getCompanyId())) { + throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); + } + // 3. 一致性校验 + if (!app.getStudentUserId().equals(interview.getStudentUserId())) { + throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); + } + return company; + } + + private void requireCompleted(Interview interview) { + if (!"COMPLETED".equals(interview.getStatus())) { + throw new BusinessException(ErrorCode.INTERVIEW_NOT_COMPLETED); + } + } + + private void validateRecommendation(String recommendation) { + if (recommendation == null || recommendation.isBlank()) { + return; + } + String v = recommendation.trim(); + if (!v.equals(InterviewScore.RECOMMENDATION_STRONG_HIRE) + && !v.equals(InterviewScore.RECOMMENDATION_HIRE) + && !v.equals(InterviewScore.RECOMMENDATION_NEUTRAL) + && !v.equals(InterviewScore.RECOMMENDATION_NO_HIRE)) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid recommendation"); + } + } + + // ── overall 计算 ───────────────────────────────────────── + + private BigDecimal computeOverall(InterviewScore score) { + if (!score.allDimensionsPresent()) { + return null; + } + int sum = score.getTechnicalScore() + score.getCommunicationScore() + + score.getProblemSolvingScore() + score.getRoleFitScore() + + score.getProfessionalismScore(); + return BigDecimal.valueOf(sum) + .divide(BigDecimal.valueOf(5), 2, RoundingMode.HALF_UP); + } + + // ── 历史 ──────────────────────────────────────────────── + + private void recordHistory(Long scoreId, Long interviewId, String fromStatus, String toStatus, + Long actorUserId, User.Role actorRole, InterviewScore snapshotSource) { + historyRepository.save(InterviewScoreHistory.builder() + .scoreId(scoreId) + .interviewId(interviewId) + .fromStatus(fromStatus) + .toStatus(toStatus) + .actorUserId(actorUserId) + .actorRole(actorRole.name()) + .snapshotText(buildSnapshot(snapshotSource)) + .build()); + } + + private String buildSnapshot(InterviewScore s) { + Map snap = new LinkedHashMap<>(); + snap.put("technicalScore", s.getTechnicalScore()); + snap.put("communicationScore", s.getCommunicationScore()); + snap.put("problemSolvingScore", s.getProblemSolvingScore()); + snap.put("roleFitScore", s.getRoleFitScore()); + snap.put("professionalismScore", s.getProfessionalismScore()); + snap.put("overallScore", s.getOverallScore()); + snap.put("recommendation", s.getRecommendation()); + snap.put("comment", s.getComment()); + try { + return objectMapper.writeValueAsString(snap); + } catch (Exception e) { + log.warn("Failed to serialize score snapshot: {}", e.getMessage()); + return "{}"; + } + } + + // ── 通知 ──────────────────────────────────────────────── + + private void notifyStudentScoreSubmitted(InterviewScore score) { + notificationService.publishEvent(score.getStudentUserId(), NOTIFICATION_TYPE_SCORE_SUBMITTED, + "面试评分已发布", "你的面试 #" + score.getInterviewId() + " 评分已发布", + "INTERVIEW", score.getInterviewId(), + "interview-score-submitted-" + score.getInterviewId()); + } + + // ── 视图映射 ──────────────────────────────────────────── + + private InterviewScoreResponse toResponse(InterviewScore s) { + return InterviewScoreResponse.builder() + .id(s.getId()) + .interviewId(s.getInterviewId()) + .applicationId(s.getApplicationId()) + .status(s.getStatus()) + .technicalScore(s.getTechnicalScore()) + .communicationScore(s.getCommunicationScore()) + .problemSolvingScore(s.getProblemSolvingScore()) + .roleFitScore(s.getRoleFitScore()) + .professionalismScore(s.getProfessionalismScore()) + .overallScore(s.getOverallScore()) + .recommendation(s.getRecommendation()) + .comment(s.getComment()) + .submittedAt(s.getSubmittedAt()) + .version(s.getVersion()) + .createdAt(s.getCreatedAt()) + .updatedAt(s.getUpdatedAt()) + .build(); + } + + private InterviewScoreHistoryItem toHistoryItem(InterviewScoreHistory h) { + Map snapshot = Map.of(); + try { + snapshot = objectMapper.readValue(h.getSnapshotText(), + new com.fasterxml.jackson.core.type.TypeReference>() {}); + } catch (Exception e) { + log.warn("Failed to parse historical snapshot: {}", e.getMessage()); + } + return InterviewScoreHistoryItem.builder() + .id(h.getId()) + .scoreId(h.getScoreId()) + .interviewId(h.getInterviewId()) + .fromStatus(h.getFromStatus()) + .toStatus(h.getToStatus()) + .actorRole(h.getActorRole()) + .snapshot(snapshot) + .createdAt(h.getCreatedAt()) + .build(); + } + + // ── 辅助 ──────────────────────────────────────────────── + + private Interview requireInterview(Long interviewId) { + return interviewRepository.findById(interviewId) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERVIEW_NOT_FOUND)); + } + + private User loadUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + } + + private boolean hasRole(User user, User.Role role) { + return user.getRoles() != null && user.getRoles().stream().anyMatch(r -> r == role); + } + + private String trimToNull(String value) { + if (value == null) return null; + String t = value.trim(); + return t.isEmpty() ? null : t; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/dto/InterviewScoreHistoryItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/dto/InterviewScoreHistoryItem.java new file mode 100644 index 0000000..427eea2 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/dto/InterviewScoreHistoryItem.java @@ -0,0 +1,30 @@ +package com.mydreamjob.interviewscore.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 面试评分历史条目(脱敏)。 + * + *

不返回 actorUserId、手机号、邮箱。snapshot 为解析后的维度分 + overallScore。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InterviewScoreHistoryItem { + + private Long id; + private Long scoreId; + private Long interviewId; + private String fromStatus; + private String toStatus; + private String actorRole; + private Map snapshot; + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/dto/InterviewScoreResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/dto/InterviewScoreResponse.java new file mode 100644 index 0000000..25d4759 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/dto/InterviewScoreResponse.java @@ -0,0 +1,41 @@ +package com.mydreamjob.interviewscore.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 面试评分响应(脱敏)。 + * + *

不返回 companyUserId、studentUserId、evaluatorUserId、手机号、邮箱等内部标识。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InterviewScoreResponse { + + private Long id; + private Long interviewId; + private Long applicationId; + private String status; + + private Integer technicalScore; + private Integer communicationScore; + private Integer problemSolvingScore; + private Integer roleFitScore; + private Integer professionalismScore; + + private BigDecimal overallScore; + private String recommendation; + private String comment; + private LocalDateTime submittedAt; + + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/dto/InterviewScoreSaveRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/dto/InterviewScoreSaveRequest.java new file mode 100644 index 0000000..3dbe955 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/dto/InterviewScoreSaveRequest.java @@ -0,0 +1,42 @@ +package com.mydreamjob.interviewscore.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 面试评分保存请求(PUT /interviews/{interviewId}/score)。 + * + *

草稿阶段五个维度可为空;提交时由服务层强制全部非空。 + * overallScore 由服务端计算,DTO 中不提供该字段(客户端传入会被忽略)。 + */ +@Data +public class InterviewScoreSaveRequest { + + @Min(value = 1, message = "technicalScore must be between 1 and 5") + @Max(value = 5, message = "technicalScore must be between 1 and 5") + private Integer technicalScore; + + @Min(value = 1, message = "communicationScore must be between 1 and 5") + @Max(value = 5, message = "communicationScore must be between 1 and 5") + private Integer communicationScore; + + @Min(value = 1, message = "problemSolvingScore must be between 1 and 5") + @Max(value = 5, message = "problemSolvingScore must be between 1 and 5") + private Integer problemSolvingScore; + + @Min(value = 1, message = "roleFitScore must be between 1 and 5") + @Max(value = 5, message = "roleFitScore must be between 1 and 5") + private Integer roleFitScore; + + @Min(value = 1, message = "professionalismScore must be between 1 and 5") + @Max(value = 5, message = "professionalismScore must be between 1 and 5") + private Integer professionalismScore; + + @Size(max = 20, message = "recommendation max length is 20") + private String recommendation; + + @Size(max = 2000, message = "comment max length is 2000") + private String comment; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteService.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteService.java index 37e6e61..0dd3959 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobFavoriteService.java @@ -88,6 +88,8 @@ public class JobFavoriteService { public Page> listFavorites(Long studentUserId, int page, int size) { assertStudentRole(studentUserId); + // size<1 直接 400,避免 PageRequest.of 抛 IllegalArgumentException 导致 500 + if (size < 1) throw new BusinessException(ErrorCode.BAD_REQUEST); if (size > MAX_PAGE_SIZE) size = MAX_PAGE_SIZE; if (page < 0) page = 0; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java b/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java index d9747be..eadd129 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/match/MatchService.java @@ -1,6 +1,8 @@ package com.mydreamjob.match; 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.job.Job; @@ -31,9 +33,20 @@ public class MatchService { private final StudentPreferenceRepository preferenceRepository; private final StudentProfileRepository profileRepository; private final AuditService auditService; + private final UserRepository userRepository; private static final ObjectMapper mapper = new ObjectMapper(); private static final String RULES_VERSION = "RULES_V0_1"; + /** 岗位匹配诊断仅 STUDENT 可用 */ + 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); + } + } + // ── 匹配规则 ────────────────────────────────────────────── public Map diagnose(Long studentUserId, Long jobId) { @@ -41,6 +54,7 @@ public class MatchService { } public Map diagnose(Long studentUserId, Long jobId, com.mydreamjob.audit.AuditContext ctx) { + assertStudentRole(studentUserId); // 岗位必须存在 Job job = jobService.getJob(jobId); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationMemberRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationMemberRepository.java index 0b26a28..f9f4a4f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationMemberRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationMemberRepository.java @@ -11,6 +11,8 @@ public interface ConversationMemberRepository extends JpaRepository findByUserId(Long userId); + List findByConversationId(Long conversationId); + boolean existsByConversationIdAndUserId(Long conversationId, Long userId); Optional findByConversationIdAndUserId(Long conversationId, Long userId); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java index 930ded3..86f72a3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java @@ -2,6 +2,8 @@ package com.mydreamjob.messaging; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.audit.SecurityAuditService; import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecordRepository; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; @@ -9,12 +11,15 @@ import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.messaging.dto.CreateConversationRequest; import com.mydreamjob.messaging.dto.SendMessageRequest; +import com.mydreamjob.safety.BlockRelationRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import java.util.*; @@ -30,6 +35,8 @@ public class MessagingService { private final UserRepository userRepository; private final CompanyRepository companyRepository; private final ApplicationMatchRecordRepository applicationMatchRecordRepository; + private final BlockRelationRepository blockRelationRepository; + private final SecurityAuditService securityAuditService; private static final int MAX_PAGE_SIZE = 50; private static final int MAX_CONTENT_LENGTH = 2000; @@ -39,6 +46,12 @@ public class MessagingService { throw new BusinessException(ErrorCode.FORBIDDEN); } + /** 统一会话存在性检查:不存在返回 404/CONVERSATION_NOT_FOUND,再进入成员校验 */ + private Conversation requireConversation(Long conversationId) { + return conversationRepository.findById(conversationId) + .orElseThrow(() -> new BusinessException(ErrorCode.CONVERSATION_NOT_FOUND)); + } + public List> listConversations(Long userId) { List memberships = memberRepository.findByUserId(userId); if (memberships.isEmpty()) return List.of(); @@ -55,9 +68,8 @@ public class MessagingService { } public Map getConversation(Long conversationId, Long userId) { + Conversation c = requireConversation(conversationId); assertMember(conversationId, userId); - Conversation c = conversationRepository.findById(conversationId) - .orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST)); Map v = toView(c); v.put("unreadCount", countUnread(conversationId, userId)); return v; @@ -76,6 +88,8 @@ public class MessagingService { if (existing != null && "DIRECT".equals(existing.getType())) return toView(existing); } } + // 新建会话:任意方向存在屏蔽 → 禁止新建 DIRECT 会话 + assertNotBlockedBetween(userId, otherId); // 新建会话:企业-求职者组合必须先存在双向匹配成功的投递,防止绕过匹配解锁 assertCompanyCandidatePairHasMatchedApplication(userId, otherId); Conversation c = conversationRepository.save(Conversation.builder().createdBy(userId).type("DIRECT").build()); @@ -114,10 +128,47 @@ public class MessagingService { return user.getRoles() != null && user.getRoles().stream().anyMatch(r -> r == role); } + // ── V33 屏蔽控制:任一方向存在屏蔽即禁止站内信交互 ── + + private boolean isBlocked(Long aId, Long bId) { + return blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(aId, bId) + || blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(bId, aId); + } + + /** + * 对外统一返回 403/2002 + 「message cannot be sent」,不泄露是谁屏蔽了谁; + * 内部由审计记录 BLOCKED_SEND_PROHIBITED。屏蔽期间发送失败的消息直接丢弃,不补发。 + */ + private void assertNotBlockedBetween(Long aId, Long bId) { + if (isBlocked(aId, bId)) { + AuditContext ctx = currentAuditContext(); + // 拒绝型安全审计以独立事务落库,不受本次 403 回滚影响 + securityAuditService.logRejected(aId, "BLOCKED_SEND_PROHIBITED", "USER", bId, + "Message send prohibited between users " + aId + " and " + bId, ctx); + log.info("Blocked send prohibited: reporter={} other={}", aId, bId); + throw new BusinessException(ErrorCode.FORBIDDEN, "message cannot be sent"); + } + } + + private AuditContext currentAuditContext() { + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs != null) return AuditContext.from(attrs.getRequest()); + return AuditContext.builder().build(); + } + @Transactional public Map sendMessage(Long userId, SendMessageRequest req) { + requireConversation(req.getConversationId()); assertMember(req.getConversationId(), userId); if (req.getContent().length() > MAX_CONTENT_LENGTH) throw new BusinessException(ErrorCode.BAD_REQUEST); + // V33 屏蔽控制:会话任一成员存在任一方向屏蔽 → 禁止发送(对外统一 403/2002) + List members = memberRepository.findByConversationId(req.getConversationId()); + for (ConversationMember m : members) { + if (!m.getUserId().equals(userId)) { + assertNotBlockedBetween(userId, m.getUserId()); + } + } Message msg = messageRepository.save(Message.builder() .conversationId(req.getConversationId()).senderUserId(userId).content(req.getContent()).build()); conversationRepository.findById(req.getConversationId()).ifPresent(c -> { @@ -127,6 +178,7 @@ public class MessagingService { } public Page> listMessages(Long conversationId, Long userId, int page, int size) { + requireConversation(conversationId); assertMember(conversationId, userId); if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST); return messageRepository.findByConversationIdOrderByCreatedAtDesc(conversationId, PageRequest.of(page, size)) @@ -139,6 +191,7 @@ public class MessagingService { @Transactional public void markRead(Long conversationId, Long userId) { + requireConversation(conversationId); assertMember(conversationId, userId); List msgs = messageRepository .findByConversationIdOrderByCreatedAtDesc(conversationId, PageRequest.of(0, 500)).getContent(); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/Offer.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/Offer.java index a6d0f2c..5d46092 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/Offer.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/Offer.java @@ -9,6 +9,7 @@ import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import java.time.LocalDate; import java.time.LocalDateTime; /** @@ -81,6 +82,21 @@ public class Offer { @Column(name = "expire_at", nullable = false) private LocalDateTime expireAt; + @Column(name = "onboarding_date") + private LocalDate onboardingDate; + + @Column(name = "base_salary") + private Integer baseSalary; + + @Column(name = "housing_allowance") + private Integer housingAllowance; + + @Column(name = "annual_bonus_months") + private Integer annualBonusMonths; + + @Column(name = "other_allowances", length = 1000) + private String otherAllowances; + @Column(name = "status", nullable = false, length = 20) @Builder.Default private String status = "DRAFT"; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferController.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferController.java index c36fa70..3a835c5 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferController.java @@ -4,6 +4,7 @@ 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.offer.dto.CounterOfferCreateRequest; import com.mydreamjob.offer.dto.OfferCreateRequest; import com.mydreamjob.offer.dto.OfferStatusRequest; import com.mydreamjob.offer.dto.OfferUpdateRequest; @@ -18,6 +19,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -74,7 +76,7 @@ public class OfferController { } @Operation(summary = "修改 Offer", description = "仅所属企业、仅 DRAFT 状态可修改;其余状态返回业务错误") - @PutMapping("/{id}") + @PutMapping("/{id:\\d+}") public ResponseEntity>> update( @PathVariable Long id, @Valid @RequestBody OfferUpdateRequest req, @@ -85,7 +87,7 @@ public class OfferController { } @Operation(summary = "Offer 状态变更", description = "DRAFT→SENT 企业;SENT/NEGOTIATING→ACCEPTED/REJECTED/NEGOTIATING 候选人;重复提交幂等") - @PatchMapping("/{id}/status") + @PatchMapping("/{id:\\d+}/status") public ResponseEntity>> changeStatus( @PathVariable Long id, @Valid @RequestBody OfferStatusRequest req, @@ -96,7 +98,7 @@ public class OfferController { } @Operation(summary = "Offer 状态时间线", description = "完整状态变更历史,含操作者、原因与请求 ID") - @GetMapping("/{id}/timeline") + @GetMapping("/{id:\\d+}/timeline") public ResponseEntity>>> timeline( @PathVariable Long id, HttpServletRequest request) { @@ -106,7 +108,7 @@ public class OfferController { } @Operation(summary = "手动提醒", description = "仅所属企业;仅 SENT/NEGOTIATING 可提醒;相同提醒不重复创建") - @PostMapping("/{id}/remind") + @PostMapping("/{id:\\d+}/remind") public ResponseEntity>> remind( @PathVariable Long id, HttpServletRequest request) { @@ -120,6 +122,100 @@ public class OfferController { return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); } + // ── Offer 进阶接口 ───────────────────────────────────────── + + @Operation(summary = "发起还价", description = "仅候选人本人;仅 SENT/NEGOTIATING 可发起;SENT 时自动进入 NEGOTIATING") + @PostMapping("/{id:\\d+}/counter-offers") + public ResponseEntity>> createCounterOffer( + @PathVariable Long id, + @Valid @RequestBody CounterOfferCreateRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + OfferCounterOffer co = offerService.createCounterOffer(userId, id, req, AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(offerService.toCounterOfferView(co), getRequestId(request))); + } + + @Operation(summary = "还价列表", description = "候选人查看本人 Offer 的还价、企业查看本企业 Offer 的还价") + @GetMapping("/{id:\\d+}/counter-offers") + public ResponseEntity>>> listCounterOffers( + @PathVariable Long id, + HttpServletRequest request) { + Long userId = resolveUserId(request); + List> items = offerService.getCounterOffers(id, userId) + .stream().map(offerService::toCounterOfferView).collect(Collectors.toList()); + return ResponseEntity.ok(ApiResponse.ok(items, getRequestId(request))); + } + + @Operation(summary = "接受还价", description = "仅所属企业;接受后更新 Offer 薪资/入职日期并变为 ACCEPTED") + @PatchMapping("/{id:\\d+}/counter-offers/{coId:\\d+}/accept") + public ResponseEntity>> acceptCounterOffer( + @PathVariable Long id, + @PathVariable Long coId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + OfferCounterOffer co = offerService.acceptCounterOffer(userId, id, coId, AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(offerService.toCounterOfferView(co), getRequestId(request))); + } + + @Operation(summary = "拒绝还价", description = "仅所属企业;拒绝后 Offer 保持 NEGOTIATING") + @PatchMapping("/{id:\\d+}/counter-offers/{coId:\\d+}/reject") + public ResponseEntity>> rejectCounterOffer( + @PathVariable Long id, + @PathVariable Long coId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + OfferCounterOffer co = offerService.rejectCounterOffer(userId, id, coId, AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(offerService.toCounterOfferView(co), getRequestId(request))); + } + + @Operation(summary = "Offer 比较", description = "仅候选人本人;比较 2-5 个非 DRAFT 的 Offer") + @GetMapping("/compare") + public ResponseEntity>>> compare( + @RequestParam("ids") String idsParam, + HttpServletRequest request) { + Long userId = resolveUserId(request); + List ids = parseIds(idsParam); + List> items = offerService.compareOffers(userId, ids); + return ResponseEntity.ok(ApiResponse.ok(items, getRequestId(request))); + } + + @Operation(summary = "Offer 统计", description = "仅企业;统计本企业 Offer 状态分布、接受率、平均响应时间") + @GetMapping("/statistics") + public ResponseEntity>> statistics( + @RequestParam(required = false, defaultValue = "6") Integer months, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Map data = offerService.getStatistics(userId, months); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + /** + * 解析逗号分隔的 Offer id 列表。 + * 非数字、空值或非法值一律返回 400/1001,绝不抛出 500。 + */ + private List parseIds(String idsParam) { + List ids = new ArrayList<>(); + if (idsParam == null || idsParam.isBlank()) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "ids is required"); + } + for (String token : idsParam.split(",")) { + String part = token.trim(); + if (part.isEmpty()) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid offer id"); + } + try { + Long id = Long.valueOf(part); + if (id <= 0) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid offer id"); + } + ids.add(id); + } catch (NumberFormatException e) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid offer id"); + } + } + return ids; + } + private Long resolveUserId(HttpServletRequest request) { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth == null || !auth.isAuthenticated()) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferCounterOffer.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferCounterOffer.java new file mode 100644 index 0000000..901def8 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferCounterOffer.java @@ -0,0 +1,86 @@ +package com.mydreamjob.offer; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 候选人还价记录 + * + *

状态:PENDING(待企业处理)/ ACCEPTED(企业已接受)/ REJECTED(企业已拒绝)/ EXPIRED(Offer 过期/撤销)。 + * {@code activeKey} 为可空判别列,配合唯一索引实现"同一 Offer 同时最多一个 PENDING 还价": + * PENDING 时为 {@code ACTIVE},ACCEPTED/REJECTED/EXPIRED 时置 {@code null} 释放槽位。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "offer_counter_offers", + uniqueConstraints = @UniqueConstraint(name = "uk_co_active", + columnNames = {"offer_id", "active_key"})) +public class OfferCounterOffer { + + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_ACCEPTED = "ACCEPTED"; + public static final String STATUS_REJECTED = "REJECTED"; + public static final String STATUS_EXPIRED = "EXPIRED"; + + public static final String ACTIVE_KEY = "ACTIVE"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "offer_id", nullable = false) + private Long offerId; + + @Column(name = "student_id", nullable = false) + private Long studentId; + + @Column(name = "proposed_salary_min") + private Integer proposedSalaryMin; + + @Column(name = "proposed_salary_max") + private Integer proposedSalaryMax; + + @Column(name = "proposed_onboarding_date") + private LocalDate proposedOnboardingDate; + + @Column(name = "reason", length = 2000) + private String reason; + + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private String status = STATUS_PENDING; + + @Column(name = "active_key", length = 64) + private String activeKey; + + @Version + @Column(name = "version", nullable = false) + @Builder.Default + private Long version = 0L; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + void prePersist() { + if (createdAt == null) createdAt = LocalDateTime.now(); + if (updatedAt == null) updatedAt = LocalDateTime.now(); + } + + @PreUpdate + void preUpdate() { + updatedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferCounterOfferRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferCounterOfferRepository.java new file mode 100644 index 0000000..708715c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferCounterOfferRepository.java @@ -0,0 +1,24 @@ +package com.mydreamjob.offer; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface OfferCounterOfferRepository extends JpaRepository { + + Optional findByOfferIdAndActiveKeyNotNull(Long offerId); + + List findByOfferIdOrderByCreatedAtAsc(Long offerId); + + /** 将 Offer 的所有 PENDING 还价标记为 EXPIRED(Offer 过期/撤销时调用) */ + @Modifying + @Query("UPDATE OfferCounterOffer co SET co.status = 'EXPIRED', co.activeKey = NULL " + + "WHERE co.offerId = :offerId AND co.status = 'PENDING'") + int expirePendingByOfferId(@Param("offerId") Long offerId); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java index 9be7d24..4d44423 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java @@ -32,6 +32,7 @@ public class OfferExpiryWorker { private final OfferRepository offerRepository; private final OfferStatusHistoryRepository historyRepository; private final OfferReminderRepository reminderRepository; + private final OfferCounterOfferRepository counterOfferRepository; private final CompanyRepository companyRepository; private final NotificationService notificationService; @@ -56,6 +57,8 @@ public class OfferExpiryWorker { .operatorRole("SYSTEM") .reason("Offer expired automatically") .build()); + // 同步将 PENDING 还价标记为 EXPIRED + counterOfferRepository.expirePendingByOfferId(saved.getId()); notifyExpired(saved); log.info("Offer {} expired ({})", saved.getId(), status); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferRepository.java index 966d6ad..825a3ee 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferRepository.java @@ -40,6 +40,24 @@ public interface OfferRepository extends JpaRepository { Page findByCompanyIdAndStatusAndApplicationIdOrderByCreatedAtDesc( Long companyId, String status, Long applicationId, Pageable pageable); + // ── Offer 比较 ──────────────────────────────────────────── + + /** 一次 JOIN 查询:候选人本人的非 DRAFT Offer,按 id 集合筛选,附带企业名称 */ + @Query("SELECT o, c.name FROM Offer o, Company c " + + "WHERE o.companyId = c.id AND o.id IN :ids AND o.candidateId = :candidateId AND o.status <> 'DRAFT'") + List findByIdInAndCandidateIdAndStatusNotDraftWithCompany( + @Param("ids") Collection ids, + @Param("candidateId") Long candidateId); + + // ── 企业统计 ────────────────────────────────────────────── + + /** 统计企业 Offer 各状态数量 */ + @Query("SELECT o.status, COUNT(o) FROM Offer o " + + "WHERE o.companyId = :companyId AND o.createdAt >= :since " + + "GROUP BY o.status") + List countByCompanyIdAndStatusSince(@Param("companyId") Long companyId, + @Param("since") LocalDateTime since); + // ── 定时任务扫描 ────────────────────────────────────────── /** 已到期的有效 Offer(SENT/NEGOTIATING 且 expire_at < now) */ diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java index e036b79..c2bdc14 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java @@ -13,6 +13,7 @@ import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobService; import com.mydreamjob.notification.NotificationService; +import com.mydreamjob.offer.dto.CounterOfferCreateRequest; import com.mydreamjob.offer.dto.OfferCreateRequest; import com.mydreamjob.offer.dto.OfferStatusRequest; import com.mydreamjob.offer.dto.OfferUpdateRequest; @@ -26,10 +27,10 @@ import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.time.temporal.ChronoUnit; +import java.util.*; import java.util.stream.Collectors; /** @@ -56,6 +57,7 @@ public class OfferService { private final OfferRepository offerRepository; private final OfferStatusHistoryRepository historyRepository; private final OfferReminderRepository reminderRepository; + private final OfferCounterOfferRepository counterOfferRepository; private final ApplicationRepository applicationRepository; private final JobService jobService; private final CompanyRepository companyRepository; @@ -111,6 +113,11 @@ public class OfferService { .benefits(req.getBenefits()) .description(req.getDescription().trim()) .expireAt(req.getExpireAt()) + .onboardingDate(req.getOnboardingDate()) + .baseSalary(req.getBaseSalary()) + .housingAllowance(req.getHousingAllowance()) + .annualBonusMonths(req.getAnnualBonusMonths()) + .otherAllowances(req.getOtherAllowances()) .status(OfferStatus.DRAFT.name()) .activeKey(ACTIVE_KEY) .createdBy(operatorUserId) @@ -223,6 +230,11 @@ public class OfferService { if (req.getBenefits() != null) offer.setBenefits(req.getBenefits()); if (req.getDescription() != null) offer.setDescription(req.getDescription().trim()); if (req.getExpireAt() != null) offer.setExpireAt(req.getExpireAt()); + if (req.getOnboardingDate() != null) offer.setOnboardingDate(req.getOnboardingDate()); + if (req.getBaseSalary() != null) offer.setBaseSalary(req.getBaseSalary()); + if (req.getHousingAllowance() != null) offer.setHousingAllowance(req.getHousingAllowance()); + if (req.getAnnualBonusMonths() != null) offer.setAnnualBonusMonths(req.getAnnualBonusMonths()); + if (req.getOtherAllowances() != null) offer.setOtherAllowances(req.getOtherAllowances().trim()); offer.setUpdatedBy(operatorUserId); offer = offerRepository.save(offer); @@ -297,13 +309,17 @@ public class OfferService { String fromStatus = offer.getStatus(); try { offer.setStatus(to.name()); - if (to == OfferStatus.REJECTED || to == OfferStatus.EXPIRED) { + if (to == OfferStatus.REJECTED || to == OfferStatus.EXPIRED || to == OfferStatus.WITHDRAWN) { offer.setActiveKey(null); // 终态释放"唯一有效 Offer"槽位 } offer.setUpdatedBy(operatorId); Offer saved = offerRepository.saveAndFlush(offer); recordHistory(saved.getId(), fromStatus, to.name(), operatorId, operatorRole, reason, ctx); + // 撤销/过期时同步将 PENDING 还价标记为 EXPIRED + if (to == OfferStatus.WITHDRAWN || to == OfferStatus.EXPIRED) { + counterOfferRepository.expirePendingByOfferId(offer.getId()); + } return saved; } catch (OptimisticLockingFailureException e) { // 清除脏上下文,避免提交时对已改脏的 offer 再次 flush 抛出异常 @@ -395,6 +411,7 @@ public class OfferService { case REJECTED -> { type = "OFFER_REJECTED"; title = "Offer 已拒绝"; } case NEGOTIATING -> { type = "OFFER_NEGOTIATING"; title = "进入洽谈"; } case EXPIRED -> { type = "OFFER_EXPIRED"; title = "Offer 已过期"; } + case WITHDRAWN -> { type = "OFFER_WITHDRAWN"; title = "Offer 已撤销"; } default -> { return; } } notificationService.publishEvent(company.getOwnerUserId(), type, title, @@ -422,6 +439,11 @@ public class OfferService { v.put("benefits", offer.getBenefits()); v.put("description", offer.getDescription()); v.put("expireAt", offer.getExpireAt()); + v.put("onboardingDate", offer.getOnboardingDate()); + v.put("baseSalary", offer.getBaseSalary()); + v.put("housingAllowance", offer.getHousingAllowance()); + v.put("annualBonusMonths", offer.getAnnualBonusMonths()); + v.put("otherAllowances", offer.getOtherAllowances()); v.put("status", offer.getStatus()); v.put("version", offer.getVersion()); v.put("createdBy", offer.getCreatedBy()); @@ -444,6 +466,311 @@ public class OfferService { return v; } + // ── 还价(候选人)──────────────────────────────────────── + + /** + * 候选人发起还价。 + * 前置条件:仅本人 Offer、SENT 或 NEGOTIATING 状态、无 PENDING 还价。 + * SENT 发起还价时,Offer 自动进入 NEGOTIATING。 + */ + @Transactional + public OfferCounterOffer createCounterOffer(Long studentUserId, Long offerId, + CounterOfferCreateRequest req, AuditContext ctx) { + Offer offer = requireOffer(offerId); + if (!offer.getCandidateId().equals(studentUserId)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + String status = offer.getStatus(); + if (!OfferStatus.SENT.name().equals(status) && !OfferStatus.NEGOTIATING.name().equals(status)) { + throw new BusinessException(ErrorCode.OFFER_NOT_NEGOTIABLE); + } + // 检查是否已有 PENDING 还价 + if (counterOfferRepository.findByOfferIdAndActiveKeyNotNull(offerId).isPresent()) { + throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_EXISTS); + } + // 过期保护 + if (offer.getExpireAt() != null && !offer.getExpireAt().isAfter(LocalDateTime.now())) { + throw new BusinessException(ErrorCode.OFFER_EXPIRED); + } + validateSalaryRange(req.getProposedSalaryMin(), req.getProposedSalaryMax()); + + // SENT → NEGOTIATING(自动流转) + if (OfferStatus.SENT.name().equals(status)) { + OfferStatusRequest sr = new OfferStatusRequest(); + sr.setStatus(OfferStatus.NEGOTIATING.name()); + sr.setReason("Candidate initiated counter-offer"); + transitionStatus(studentUserId, offerId, sr, ctx); + // 重新加载(transitionStatus 已刷新) + offer = requireOffer(offerId); + } + + OfferCounterOffer co = OfferCounterOffer.builder() + .offerId(offerId) + .studentId(studentUserId) + .proposedSalaryMin(req.getProposedSalaryMin()) + .proposedSalaryMax(req.getProposedSalaryMax()) + .proposedOnboardingDate(req.getProposedOnboardingDate()) + .reason(req.getReason()) + .status(OfferCounterOffer.STATUS_PENDING) + .activeKey(OfferCounterOffer.ACTIVE_KEY) + .build(); + try { + co = counterOfferRepository.save(co); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + log.info("Duplicate active counter-offer for offer {}", offerId); + throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_EXISTS); + } + auditService.log(studentUserId, "COUNTER_OFFER", "OFFER", offerId, + "Counter-offer created for offer " + offerId, ctx); + return co; + } + + /** 查看还价列表 */ + public List getCounterOffers(Long offerId, Long userId) { + Offer offer = getOffer(offerId, userId); + return counterOfferRepository.findByOfferIdOrderByCreatedAtAsc(offerId); + } + + /** + * 企业接受还价:更新 Offer 薪资和入职日期,将 Offer 变为 ACCEPTED。 + */ + @Transactional + public OfferCounterOffer acceptCounterOffer(Long companyUserId, Long offerId, Long coId, AuditContext ctx) { + Company company = requireCompany(companyUserId); + Offer offer = requireOffer(offerId); + if (!company.getId().equals(offer.getCompanyId())) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + OfferCounterOffer co = counterOfferRepository.findById(coId) + .orElseThrow(() -> new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND)); + if (!co.getOfferId().equals(offerId)) { + throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND); + } + if (!OfferCounterOffer.STATUS_PENDING.equals(co.getStatus())) { + throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING); + } + if (OfferStatus.WITHDRAWN.name().equals(offer.getStatus()) + || OfferStatus.EXPIRED.name().equals(offer.getStatus())) { + // Offer 已撤销/过期,标记还价为 EXPIRED + co.setStatus(OfferCounterOffer.STATUS_EXPIRED); + co.setActiveKey(null); + counterOfferRepository.saveAndFlush(co); + throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_EXPIRED); + } + + try { + // 更新 Offer 字段 + if (co.getProposedSalaryMin() != null) offer.setSalaryMin(co.getProposedSalaryMin()); + if (co.getProposedSalaryMax() != null) offer.setSalaryMax(co.getProposedSalaryMax()); + if (co.getProposedOnboardingDate() != null) offer.setOnboardingDate(co.getProposedOnboardingDate()); + offer.setUpdatedBy(companyUserId); + offerRepository.saveAndFlush(offer); + + // 更新还价状态 + co.setStatus(OfferCounterOffer.STATUS_ACCEPTED); + co.setActiveKey(null); + co = counterOfferRepository.saveAndFlush(co); + } catch (OptimisticLockingFailureException e) { + entityManager.clear(); + OfferCounterOffer fresh = counterOfferRepository.findById(coId) + .orElseThrow(() -> new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND)); + if (OfferCounterOffer.STATUS_ACCEPTED.equals(fresh.getStatus())) return fresh; + if (OfferCounterOffer.STATUS_REJECTED.equals(fresh.getStatus())) + throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING); + throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING); + } + + // 将 Offer 变为 ACCEPTED + OfferStatusRequest sr = new OfferStatusRequest(); + sr.setStatus(OfferStatus.ACCEPTED.name()); + sr.setReason("Counter-offer accepted by company"); + transitionStatus(companyUserId, offerId, sr, ctx); + + auditService.log(companyUserId, "ACCEPT_COUNTER_OFFER", "OFFER", offerId, + "Counter-offer " + coId + " accepted", ctx); + return co; + } + + /** + * 企业拒绝还价:还价变为 REJECTED,Offer 保持 NEGOTIATING。 + */ + @Transactional + public OfferCounterOffer rejectCounterOffer(Long companyUserId, Long offerId, Long coId, AuditContext ctx) { + Company company = requireCompany(companyUserId); + Offer offer = requireOffer(offerId); + if (!company.getId().equals(offer.getCompanyId())) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + OfferCounterOffer co = counterOfferRepository.findById(coId) + .orElseThrow(() -> new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND)); + if (!co.getOfferId().equals(offerId)) { + throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND); + } + if (!OfferCounterOffer.STATUS_PENDING.equals(co.getStatus())) { + if (OfferCounterOffer.STATUS_REJECTED.equals(co.getStatus())) return co; + throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING); + } + if (OfferStatus.WITHDRAWN.name().equals(offer.getStatus()) + || OfferStatus.EXPIRED.name().equals(offer.getStatus())) { + co.setStatus(OfferCounterOffer.STATUS_EXPIRED); + co.setActiveKey(null); + counterOfferRepository.saveAndFlush(co); + throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_EXPIRED); + } + + try { + co.setStatus(OfferCounterOffer.STATUS_REJECTED); + co.setActiveKey(null); + co = counterOfferRepository.saveAndFlush(co); + } catch (OptimisticLockingFailureException e) { + entityManager.clear(); + OfferCounterOffer fresh = counterOfferRepository.findById(coId) + .orElseThrow(() -> new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND)); + if (OfferCounterOffer.STATUS_REJECTED.equals(fresh.getStatus())) return fresh; + throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING); + } + + auditService.log(companyUserId, "REJECT_COUNTER_OFFER", "OFFER", offerId, + "Counter-offer " + coId + " rejected", ctx); + return co; + } + + // ── Offer 比较(候选人)─────────────────────────────────── + + /** + * 候选人比较多个 Offer。 + * ids 必须是 2-5 个、不可重复、全部为正数;只能比较本人非 DRAFT 的 Offer。 + * 一次 JOIN 查询,返回 Offer 附带企业名称。 + */ + public List> compareOffers(Long studentUserId, List ids) { + if (ids == null || ids.size() < 2 || ids.size() > 5) { + throw new BusinessException(ErrorCode.OFFER_COMPARE_TOO_MANY); + } + Set idSet = new HashSet<>(ids); + if (idSet.size() != ids.size()) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "duplicate offer ids"); + } + for (Long id : ids) { + if (id == null || id <= 0) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid offer id"); + } + } + + List results = offerRepository.findByIdInAndCandidateIdAndStatusNotDraftWithCompany(idSet, studentUserId); + if (results.size() != idSet.size()) { + throw new BusinessException(ErrorCode.OFFER_COMPARE_NOT_OWNER); + } + + return results.stream().map(row -> { + Offer offer = (Offer) row[0]; + String companyName = (String) row[1]; + Map v = toView(offer); + v.put("companyName", companyName); + return v; + }).collect(Collectors.toList()); + } + + // ── Offer 统计(企业)───────────────────────────────────── + + /** + * 企业 Offer 统计看板。 + * months 默认 6,范围 1-24。 + * acceptanceRate = ACCEPTED / (ACCEPTED + REJECTED),无数据时返回 null。 + * avgResponseHours 通过状态历史计算 SENT→ACCEPTED/REJECTED 的平均时间,无数据时返回 null。 + */ + public Map getStatistics(Long companyUserId, Integer months) { + Company company = requireCompany(companyUserId); + if (months == null || months < 1 || months > 24) { + months = 6; + } + + LocalDateTime since = LocalDateTime.now().minusMonths(months); + List statusCounts = offerRepository.countByCompanyIdAndStatusSince(company.getId(), since); + + Map byStatus = new LinkedHashMap<>(); + long total = 0; + long accepted = 0, rejected = 0; + for (Object[] row : statusCounts) { + String status = (String) row[0]; + Long count = (Long) row[1]; + byStatus.put(status, count); + total += count; + if (OfferStatus.ACCEPTED.name().equals(status)) accepted = count; + if (OfferStatus.REJECTED.name().equals(status)) rejected = count; + } + + // 补全所有状态 + for (OfferStatus s : OfferStatus.values()) { + byStatus.putIfAbsent(s.name(), 0L); + } + + Double acceptanceRate = null; + long sum = accepted + rejected; + if (sum > 0) { + acceptanceRate = (double) accepted / sum; + } + + Double avgResponseHours = computeAvgResponseHours(company.getId(), since); + + Map result = new HashMap<>(); + result.put("totalOffers", total); + result.put("byStatus", byStatus); + result.put("acceptanceRate", acceptanceRate); + result.put("avgResponseHours", avgResponseHours); + result.put("periodMonths", months); + return result; + } + + private Double computeAvgResponseHours(Long companyId, LocalDateTime since) { + // 通过 status_history 中 SENT → ACCEPTED/REJECTED 的时间差计算平均响应时间 + List histories = historyRepository.findByOfferCompanyAndSince(companyId, since); + if (histories.isEmpty()) return null; + + // 按 offer_id 分组,找到每个 Offer 的 SENT 时间和第一个终态时间 + Map sentTimes = new HashMap<>(); + Map terminalTimes = new HashMap<>(); + + for (OfferStatusHistory h : histories) { + Long oid = h.getOfferId(); + if ("SENT".equals(h.getToStatus()) && h.getCreatedAt() != null) { + sentTimes.putIfAbsent(oid, h.getCreatedAt()); + } + if (("ACCEPTED".equals(h.getToStatus()) || "REJECTED".equals(h.getToStatus())) + && h.getCreatedAt() != null) { + terminalTimes.putIfAbsent(oid, h.getCreatedAt()); + } + } + + double totalHours = 0; + int count = 0; + for (Map.Entry entry : terminalTimes.entrySet()) { + LocalDateTime sent = sentTimes.get(entry.getKey()); + if (sent != null) { + totalHours += ChronoUnit.HOURS.between(sent, entry.getValue()); + count++; + } + } + return count > 0 ? totalHours / count : null; + } + + // ── 还价视图 ────────────────────────────────────────────── + + public Map toCounterOfferView(OfferCounterOffer co) { + Map v = new HashMap<>(); + v.put("id", co.getId()); + v.put("offerId", co.getOfferId()); + v.put("studentId", co.getStudentId()); + v.put("proposedSalaryMin", co.getProposedSalaryMin()); + v.put("proposedSalaryMax", co.getProposedSalaryMax()); + v.put("proposedOnboardingDate", co.getProposedOnboardingDate()); + v.put("reason", co.getReason()); + v.put("status", co.getStatus()); + v.put("version", co.getVersion()); + v.put("createdAt", co.getCreatedAt()); + v.put("updatedAt", co.getUpdatedAt()); + return v; + } + // ── 私有辅助 ────────────────────────────────────────────── private Offer requireOffer(Long offerId) { @@ -516,17 +843,20 @@ public class OfferService { return s.length() > 32 ? s.substring(0, 32) : s; } - /** 校验角色是否可发起某流转(EXPIRED 仅系统任务可触发) */ + /** 校验角色是否可发起某流转(EXPIRED 仅系统任务可触发,WITHDRAWN 仅企业可触发) */ private boolean isRoleAllowed(OfferStatus from, OfferStatus to, ActorRole actor) { return switch (from) { case DRAFT -> actor == ActorRole.COMPANY && to == OfferStatus.SENT; case SENT -> switch (to) { case NEGOTIATING, ACCEPTED, REJECTED -> actor == ActorRole.CANDIDATE; + case WITHDRAWN -> actor == ActorRole.COMPANY; case EXPIRED -> false; // 仅系统任务 default -> false; }; case NEGOTIATING -> switch (to) { - case ACCEPTED, REJECTED -> actor == ActorRole.CANDIDATE; + case ACCEPTED -> actor == ActorRole.CANDIDATE || actor == ActorRole.COMPANY; + case REJECTED -> actor == ActorRole.CANDIDATE; + case WITHDRAWN -> actor == ActorRole.COMPANY; case EXPIRED -> false; // 仅系统任务 default -> false; }; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatus.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatus.java index 798ea9e..54bb643 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatus.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatus.java @@ -19,16 +19,17 @@ public enum OfferStatus { NEGOTIATING, ACCEPTED, REJECTED, - EXPIRED; + EXPIRED, + WITHDRAWN; /** * 校验状态流转是否合法: *

      * DRAFT -------> SENT
-     * SENT ---------> NEGOTIATING / ACCEPTED / REJECTED / EXPIRED
-     * NEGOTIATING --> ACCEPTED / REJECTED / EXPIRED
+     * SENT ---------> NEGOTIATING / ACCEPTED / REJECTED / EXPIRED / WITHDRAWN
+     * NEGOTIATING --> ACCEPTED / REJECTED / EXPIRED / WITHDRAWN
      * 
- * 终态(ACCEPTED/REJECTED/EXPIRED)不可再流转。 + * 终态(ACCEPTED/REJECTED/EXPIRED/WITHDRAWN)不可再流转。 */ public boolean canTransitionTo(OfferStatus target) { if (target == null) return false; @@ -37,16 +38,18 @@ public enum OfferStatus { case SENT -> target == NEGOTIATING || target == ACCEPTED || target == REJECTED - || target == EXPIRED; + || target == EXPIRED + || target == WITHDRAWN; case NEGOTIATING -> target == ACCEPTED || target == REJECTED - || target == EXPIRED; - case ACCEPTED, REJECTED, EXPIRED -> false; // 终态不可再流转 + || target == EXPIRED + || target == WITHDRAWN; + case ACCEPTED, REJECTED, EXPIRED, WITHDRAWN -> false; // 终态不可再流转 }; } /** 是否为终态 */ public boolean isTerminal() { - return this == ACCEPTED || this == REJECTED || this == EXPIRED; + return this == ACCEPTED || this == REJECTED || this == EXPIRED || this == WITHDRAWN; } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistoryRepository.java index 75ab799..97a1772 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistoryRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistoryRepository.java @@ -1,12 +1,22 @@ package com.mydreamjob.offer; 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.time.LocalDateTime; import java.util.List; @Repository public interface OfferStatusHistoryRepository extends JpaRepository { List findByOfferIdOrderByCreatedAtAsc(Long offerId); + + /** 查询某企业指定时间范围内的状态历史(用于统计平均响应时间) */ + @Query("SELECT h FROM OfferStatusHistory h, Offer o " + + "WHERE h.offerId = o.id AND o.companyId = :companyId AND h.createdAt >= :since " + + "ORDER BY h.offerId, h.createdAt ASC") + List findByOfferCompanyAndSince(@Param("companyId") Long companyId, + @Param("since") LocalDateTime since); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/CounterOfferCreateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/CounterOfferCreateRequest.java new file mode 100644 index 0000000..cc1523f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/CounterOfferCreateRequest.java @@ -0,0 +1,27 @@ +package com.mydreamjob.offer.dto; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDate; + +/** + * 候选人发起还价请求 + */ +@Data +public class CounterOfferCreateRequest { + + @Min(value = 0, message = "proposedSalaryMin must not be negative") + private Integer proposedSalaryMin; + + @Min(value = 0, message = "proposedSalaryMax must not be negative") + private Integer proposedSalaryMax; + + @Future(message = "proposedOnboardingDate must be in the future") + private LocalDate proposedOnboardingDate; + + @Size(max = 2000, message = "reason must be at most 2000 characters") + private String reason; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferCreateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferCreateRequest.java index 5fd576c..429d57f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferCreateRequest.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferCreateRequest.java @@ -3,6 +3,7 @@ package com.mydreamjob.offer.dto; import jakarta.validation.constraints.*; import lombok.Data; +import java.time.LocalDate; import java.time.LocalDateTime; /** @@ -52,4 +53,19 @@ public class OfferCreateRequest { @NotNull(message = "expireAt is required") @Future(message = "expireAt must be in the future") private LocalDateTime expireAt; + + @Future(message = "onboardingDate must be in the future") + private LocalDate onboardingDate; + + @Min(value = 0, message = "baseSalary must not be negative") + private Integer baseSalary; + + @Min(value = 0, message = "housingAllowance must not be negative") + private Integer housingAllowance; + + @Min(value = 0, message = "annualBonusMonths must not be negative") + private Integer annualBonusMonths; + + @Size(max = 1000, message = "otherAllowances must be at most 1000 characters") + private String otherAllowances; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferStatusRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferStatusRequest.java index a7241e2..7e8eef4 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferStatusRequest.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferStatusRequest.java @@ -15,8 +15,8 @@ import lombok.Data; public class OfferStatusRequest { @NotBlank(message = "status is required") - @Pattern(regexp = "^(SENT|NEGOTIATING|ACCEPTED|REJECTED|EXPIRED)$", - message = "status must be SENT, NEGOTIATING, ACCEPTED, REJECTED, or EXPIRED") + @Pattern(regexp = "^(SENT|NEGOTIATING|ACCEPTED|REJECTED|EXPIRED|WITHDRAWN)$", + message = "status must be SENT, NEGOTIATING, ACCEPTED, REJECTED, EXPIRED, or WITHDRAWN") private String status; @Size(max = 255, message = "reason must be at most 255 characters") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferUpdateRequest.java index 23c5966..aac9a4f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferUpdateRequest.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/dto/OfferUpdateRequest.java @@ -3,6 +3,7 @@ package com.mydreamjob.offer.dto; import jakarta.validation.constraints.*; import lombok.Data; +import java.time.LocalDate; import java.time.LocalDateTime; /** @@ -45,4 +46,19 @@ public class OfferUpdateRequest { @Future(message = "expireAt must be in the future") private LocalDateTime expireAt; + + @Future(message = "onboardingDate must be in the future") + private LocalDate onboardingDate; + + @Min(value = 0, message = "baseSalary must not be negative") + private Integer baseSalary; + + @Min(value = 0, message = "housingAllowance must not be negative") + private Integer housingAllowance; + + @Min(value = 0, message = "annualBonusMonths must not be negative") + private Integer annualBonusMonths; + + @Size(max = 1000, message = "otherAllowances must be at most 1000 characters") + private String otherAllowances; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java new file mode 100644 index 0000000..8a16618 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java @@ -0,0 +1,158 @@ +package com.mydreamjob.privacy; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.privacy.dto.PrivacySettingsResponse; +import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest; +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 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.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "隐私设置", description = "学生对自己简历可见性与联系方式可见性的隐私设置(仅 STUDENT 角色)") +@RestController +@RequestMapping("/profile/me/privacy") +@RequiredArgsConstructor +public class PrivacySettingsController { + + private final PrivacySettingsService privacySettingsService; + + @Operation( + summary = "查询当前学生隐私设置", + description = "查询当前登录学生(仅 STUDENT 角色)的隐私设置。" + + "身份仅从 JWT/SecurityContext 获取,不接受 userId、studentUserId 等请求参数;" + + "本接口无 Path、Query、Body 参数。" + + "若尚未创建设置:HTTP 200,返回默认对象(PRIVATE / contactVisible=false / version=0)," + + "且不会自动创建数据库记录。", + security = @SecurityRequirement(name = "bearerAuth"), + parameters = {}) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "成功;返回隐私设置对象(无记录时返回默认对象);message=ok", + content = @Content( + mediaType = "application/json", + schema = @Schema(ref = "#/components/schemas/ApiResponsePrivacySettingsResponse"), + examples = @ExampleObject( + name = "success-with-data", + summary = "已有设置记录", + value = "{\"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\"}"))), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "未登录、Token 缺失/非法/过期;运行时实际返回 code=401、message=not authenticated", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "unauthorized", + value = "{\"code\":401,\"message\":\"not authenticated\"," + + "\"timestamp\":\"2026-08-25T10:00:00Z\"}"))), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "已登录但不是 STUDENT 角色(COMPANY/ADMIN);code=2002、message=forbidden", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "forbidden", + value = "{\"code\":2002,\"message\":\"forbidden\",\"data\":null," + + "\"timestamp\":\"2026-08-25T10:00:00Z\"}"))) + }) + @GetMapping + public ResponseEntity> getMyPrivacySettings(HttpServletRequest request) { + Long userId = resolveUserId(request); + PrivacySettingsResponse response = privacySettingsService.getCurrentUserSettings(userId); + return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); + } + + @Operation( + summary = "更新当前学生隐私设置", + description = "更新当前登录学生(仅 STUDENT 角色)的隐私设置,执行 upsert(同一学生至多一行)。" + + "身份仅从 JWT/SecurityContext 获取,不接受 userId、studentUserId 等请求参数。" + + "resumeVisibility 必填,取值 PRIVATE / APPLIED_COMPANIES / VERIFIED_COMPANIES;" + + "contactVisible 必填。" + + "version 可选:不传或传 0 表示非严格校验;传了且与当前版本不一致时返回 409/9201。", + security = @SecurityRequirement(name = "bearerAuth"), + parameters = {}) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "成功;返回更新后的隐私设置对象;message=ok", + content = @Content( + mediaType = "application/json", + schema = @Schema(ref = "#/components/schemas/ApiResponsePrivacySettingsResponse"))), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "请求体校验失败(缺少必填字段或非法枚举);code=1001、message=bad request", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "bad-request", + value = "{\"code\":1001,\"message\":\"bad request\",\"data\":null," + + "\"timestamp\":\"2026-08-25T10:00:00Z\"}"))), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "未登录、Token 缺失/非法/过期;code=401、message=not authenticated", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "unauthorized", + value = "{\"code\":401,\"message\":\"not authenticated\"," + + "\"timestamp\":\"2026-08-25T10:00:00Z\"}"))), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "已登录但不是 STUDENT 角色(COMPANY/ADMIN);code=2002、message=forbidden", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "forbidden", + value = "{\"code\":2002,\"message\":\"forbidden\",\"data\":null," + + "\"timestamp\":\"2026-08-25T10:00:00Z\"}"))), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "409", + description = "乐观锁版本冲突(version 与当前不一致);code=9201、message=privacy settings concurrent update", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "conflict", + value = "{\"code\":9201,\"message\":\"privacy settings concurrent update\"," + + "\"data\":null,\"timestamp\":\"2026-08-25T10:00:00Z\"}"))) + }) + @PutMapping + public ResponseEntity> updateMyPrivacySettings( + @Valid @RequestBody PrivacySettingsUpdateRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + PrivacySettingsResponse response = privacySettingsService.updateCurrentUserSettings(userId, req); + return ResponseEntity.ok(ApiResponse.ok(response, 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"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java new file mode 100644 index 0000000..d99c992 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java @@ -0,0 +1,162 @@ +package com.mydreamjob.privacy; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.privacy.dto.PrivacySettingsResponse; +import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Set; + +/** + * 学生隐私设置服务。 + * + *

身份由调用方从 JWT/SecurityContext 解析后传入 userId,本服务负责校验 STUDENT 角色; + * 不接受客户端指定的 userId 身份参数。联系方式的可见性策略方法供企业侧脱敏接入使用。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PrivacySettingsService { + + /** 视为"非有效投递"的状态:撤销或已拒绝的投递不允许企业查看简历 */ + private static final Set INEFFECTIVE_STATUSES = Set.of("WITHDRAWN", "REJECTED"); + + private final StudentPrivacySettingsRepository privacyRepository; + private final UserRepository userRepository; + private final ApplicationRepository applicationRepository; + private final CompanyRepository companyRepository; + private final AuditService auditService; + + /** 校验当前用户必须为 STUDENT,否则抛 FORBIDDEN。 */ + public void assertStudentRole(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + boolean isStudent = user.getRoles() != null + && user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT); + if (!isStudent) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } + + /** + * 获取当前学生的隐私设置;无记录时返回默认对象(PRIVATE / contactVisible=false / version=0), + * 不自动创建数据库记录。 + */ + @Transactional(readOnly = true) + public PrivacySettingsResponse getCurrentUserSettings(Long userId) { + assertStudentRole(userId); + StudentPrivacySettings settings = privacyRepository.findByStudentUserId(userId).orElse(null); + return toResponse(settings, userId); + } + + /** + * 更新当前学生的隐私设置,执行 upsert,确保同一学生至多一行。 + * version 不匹配时抛 PRIVACY_SETTINGS_CONCURRENT_UPDATE(9201 → HTTP 409)。 + */ + @Transactional + public PrivacySettingsResponse updateCurrentUserSettings(Long userId, PrivacySettingsUpdateRequest req) { + assertStudentRole(userId); + validate(req); + + StudentPrivacySettings existing = privacyRepository.findByStudentUserId(userId).orElse(null); + if (existing != null && req.getVersion() != null) { + long expected = req.getVersion(); + long current = existing.getVersion() == null ? 0L : existing.getVersion(); + if (current != expected) { + throw new BusinessException(ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE); + } + } + + try { + StudentPrivacySettings saved; + if (existing == null) { + saved = privacyRepository.saveAndFlush(StudentPrivacySettings.builder() + .studentUserId(userId) + .resumeVisibility(req.getResumeVisibility()) + .contactVisible(req.getContactVisible()) + .version(0L) + .build()); + } else { + existing.setResumeVisibility(req.getResumeVisibility()); + existing.setContactVisible(req.getContactVisible()); + saved = privacyRepository.saveAndFlush(existing); + } + // 审计:仅记录事件,不写入 phone/email/password/jwt/resumeUrl 等敏感信息 + auditService.log(userId, "UPDATE_PRIVACY_SETTINGS", "STUDENT_PRIVACY_SETTINGS", + saved.getId(), "privacy settings updated", null); + return toResponse(saved, userId); + } catch (ObjectOptimisticLockingFailureException | DataIntegrityViolationException e) { + log.warn("Concurrent privacy settings update conflict for userId={}", userId, e); + throw new BusinessException(ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE); + } + } + + /** + * 企业是否可查看某学生的简历。 + * + *

    + *
  • PRIVATE:企业不可见
  • + *
  • APPLIED_COMPANIES:仅当该学生在该企业存在有效投递时可见(WITHDRAWN/REJECTED 不算)
  • + *
  • VERIFIED_COMPANIES:仅当企业 verified=true 时可见
  • + *
+ */ + public boolean canCompanyViewResume(Long studentUserId, Long companyId, Long applicationId) { + StudentPrivacySettings settings = privacyRepository.findByStudentUserId(studentUserId).orElse(null); + ResumeVisibility visibility = (settings == null || settings.getResumeVisibility() == null) + ? ResumeVisibility.PRIVATE : settings.getResumeVisibility(); + switch (visibility) { + case VERIFIED_COMPANIES: + Company company = companyRepository.findById(companyId).orElse(null); + return company != null && Boolean.TRUE.equals(company.getVerified()); + case APPLIED_COMPANIES: + // 投递表不含 companyId,以 applicationId 定位具体投递校验有效状态(WITHDRAWN/REJECTED 不算有效) + Application app = applicationRepository.findById(applicationId).orElse(null); + if (app == null || !studentUserId.equals(app.getStudentUserId())) { + return false; + } + return !INEFFECTIVE_STATUSES.contains(app.getStatus()); + case PRIVATE: + default: + return false; + } + } + + private void validate(PrivacySettingsUpdateRequest req) { + if (req.getResumeVisibility() == null || req.getContactVisible() == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + } + + private PrivacySettingsResponse toResponse(StudentPrivacySettings s, Long userId) { + if (s == null) { + return PrivacySettingsResponse.builder() + .studentUserId(userId) + .resumeVisibility(ResumeVisibility.PRIVATE) + .contactVisible(false) + .version(0L) + .build(); + } + return PrivacySettingsResponse.builder() + .id(s.getId()) + .studentUserId(s.getStudentUserId()) + .resumeVisibility(s.getResumeVisibility()) + .contactVisible(s.getContactVisible()) + .version(s.getVersion()) + .createdAt(s.getCreatedAt()) + .updatedAt(s.getUpdatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/ResumeVisibility.java b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/ResumeVisibility.java new file mode 100644 index 0000000..e40a709 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/ResumeVisibility.java @@ -0,0 +1,40 @@ +package com.mydreamjob.privacy; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 简历可见性(隐私设置)。 + * + *
    + *
  • {@link #PRIVATE} 企业不可见(默认)
  • + *
  • {@link #APPLIED_COMPANIES} 仅已投递(存在有效投递)的企业可见
  • + *
  • {@link #VERIFIED_COMPANIES} 仅已认证企业可见
  • + *
+ */ +@Schema(description = "简历可见性") +public enum ResumeVisibility { + + PRIVATE, + APPLIED_COMPANIES, + VERIFIED_COMPANIES; + + /** + * 反序列化:大小写不敏感;非法值返回 {@code null},交由后续 @NotNull 校验给出 400。 + */ + @JsonCreator + public static ResumeVisibility from(String value) { + if (value == null || value.isBlank()) return null; + try { + return ResumeVisibility.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + return null; + } + } + + @JsonValue + public String toValue() { + return name(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/StudentPrivacySettings.java b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/StudentPrivacySettings.java new file mode 100644 index 0000000..3d0ffe1 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/StudentPrivacySettings.java @@ -0,0 +1,55 @@ +package com.mydreamjob.privacy; + +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.time.LocalDateTime; + +/** + * 学生隐私设置实体。 + * + *

每个学生至多一行(student_user_id 唯一),通过 @Version 乐观锁处理并发更新。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "student_privacy_settings") +@EntityListeners(AuditingEntityListener.class) +public class StudentPrivacySettings { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "student_user_id", nullable = false, unique = true) + private Long studentUserId; + + @Enumerated(EnumType.STRING) + @Column(name = "resume_visibility", nullable = false, length = 32) + @Builder.Default + private ResumeVisibility resumeVisibility = ResumeVisibility.PRIVATE; + + @Column(name = "contact_visible", nullable = false) + @Builder.Default + private Boolean contactVisible = false; + + @Version + @Column(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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/StudentPrivacySettingsRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/StudentPrivacySettingsRepository.java new file mode 100644 index 0000000..ba9f12e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/StudentPrivacySettingsRepository.java @@ -0,0 +1,12 @@ +package com.mydreamjob.privacy; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface StudentPrivacySettingsRepository extends JpaRepository { + + Optional findByStudentUserId(Long studentUserId); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/dto/PrivacySettingsResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/dto/PrivacySettingsResponse.java new file mode 100644 index 0000000..d10ee51 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/dto/PrivacySettingsResponse.java @@ -0,0 +1,48 @@ +package com.mydreamjob.privacy.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.mydreamjob.privacy.ResumeVisibility; +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(GET/PUT /profile/me/privacy)。 + * + *

无记录时返回默认对象 {@code resumeVisibility=PRIVATE、contactVisible=false、version=0}, + * 不自动创建数据库记录。null 字段不序列化。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PrivacySettingsResponse { + + @Schema(description = "隐私设置 ID;有记录时返回", example = "1") + private Long id; + + @Schema(description = "学生用户 ID", example = "2") + private Long studentUserId; + + @Schema(description = "简历可见性", + example = "PRIVATE", + allowableValues = {"PRIVATE", "APPLIED_COMPANIES", "VERIFIED_COMPANIES"}) + private ResumeVisibility resumeVisibility; + + @Schema(description = "联系方式是否对企业可见", example = "false") + private Boolean contactVisible; + + @Schema(description = "乐观锁版本号", example = "0") + private Long version; + + @Schema(description = "创建时间;有记录时返回", format = "date-time") + private LocalDateTime createdAt; + + @Schema(description = "更新时间;有记录时返回", format = "date-time") + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/dto/PrivacySettingsUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/dto/PrivacySettingsUpdateRequest.java new file mode 100644 index 0000000..f5b75ae --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/dto/PrivacySettingsUpdateRequest.java @@ -0,0 +1,28 @@ +package com.mydreamjob.privacy.dto; + +import com.mydreamjob.privacy.ResumeVisibility; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 隐私设置更新请求(PUT /profile/me/privacy)。 + * + *

resumeVisibility 与 contactVisible 均为必填;version 可选,开启后用于乐观锁并发控制。 + */ +@Data +public class PrivacySettingsUpdateRequest { + + @Schema(description = "简历可见性(必填)", + example = "APPLIED_COMPANIES", + allowableValues = {"PRIVATE", "APPLIED_COMPANIES", "VERIFIED_COMPANIES"}) + @NotNull(message = "resumeVisibility is required") + private ResumeVisibility resumeVisibility; + + @Schema(description = "联系方式是否对企业可见(必填)", example = "true") + @NotNull(message = "contactVisible is required") + private Boolean contactVisible; + + @Schema(description = "乐观锁版本号(可选);不传或传 0 表示非严格校验", example = "0") + private Long version; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java index dc980bb..4a5a8c3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java @@ -1,8 +1,14 @@ package com.mydreamjob.profile; +import com.mydreamjob.profile.dto.StudentProfileResponse; import com.mydreamjob.profile.dto.StudentProfileUpdateRequest; import com.mydreamjob.common.api.ApiResponse; 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 jakarta.validation.Valid; @@ -29,12 +35,55 @@ public class ProfileController { // ── 资料 CRUD ──────────────────────────────────────────── - @Operation(summary = "查询当前学生资料", description = "未创建资料时返回 200 空对象,不自动创建") + @Operation( + summary = "查询当前学生资料", + description = "查询当前登录学生(仅 STUDENT 角色)自己的学生资料。" + + "身份仅从 JWT/SecurityContext 获取,不接受 userId、studentUserId 等请求参数;" + + "本接口无 Path、Query、Body 参数。" + + "若尚未创建个人资料:HTTP 200,data 为空对象 {}(非 null),且不会自动创建数据库记录。" + + "当前未设置接口级限流。", + security = @SecurityRequirement(name = "bearerAuth"), + parameters = {}) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "成功;有资料返回资料对象,无资料返回空对象 {};message=ok", + content = @Content( + mediaType = "application/json", + schema = @Schema(ref = "#/components/schemas/ApiResponseStudentProfileResponse"), + examples = @ExampleObject( + name = "success-with-data", + summary = "有资料", + value = "{\"code\":200,\"message\":\"ok\",\"data\":{\"id\":1,\"userId\":2," + + "\"realName\":\"张三\",\"education\":\"本科\",\"school\":\"某某大学\"," + + "\"graduationYear\":2026,\"resumeUrl\":\"/uploads/resumes/2/resume.pdf\"," + + "\"skills\":\"[Java, Spring Boot]\",\"createdAt\":\"2026-08-24T10:00:00\"}," + + "\"timestamp\":\"2026-08-24T10:00:00Z\"}"))), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "未登录、Token 缺失/非法/过期;运行时实际返回 code=401、message=not authenticated", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "unauthorized", + value = "{\"code\":401,\"message\":\"not authenticated\"," + + "\"timestamp\":\"2026-08-24T10:00:00Z\"}"))), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "已登录但不是 STUDENT 角色(COMPANY/ADMIN);code=2002、message=forbidden", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "forbidden", + value = "{\"code\":2002,\"message\":\"forbidden\",\"data\":null," + + "\"timestamp\":\"2026-08-24T10:00:00Z\"}"))) + }) @GetMapping("/me") - public ResponseEntity>> getMyProfile(HttpServletRequest request) { + public ResponseEntity> getMyProfile(HttpServletRequest request) { Long userId = resolveUserId(request); - Map view = profileService.toView(profileService.findProfileByUserId(userId)); - return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); + profileService.assertStudentRole(userId); + StudentProfileResponse response = profileService.toResponse(profileService.findProfileByUserId(userId)); + return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); } @Operation(summary = "更新学生资料", description = "仅更新传入的非 null 字段;不存在则自动创建") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java index deba79f..f1345bd 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java @@ -1,7 +1,10 @@ package com.mydreamjob.profile; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.profile.dto.StudentProfileResponse; import com.mydreamjob.profile.dto.StudentProfileUpdateRequest; import com.mydreamjob.profile.storage.ResumeStorageService; import com.mydreamjob.auth.User; @@ -14,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Set; @@ -26,6 +30,8 @@ public class ProfileService { private final ResumeStorageService resumeStorageService; private final UserRepository userRepository; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final Set ALLOWED_CONTENT_TYPES = Set.of( "application/pdf", "application/msword", @@ -68,10 +74,23 @@ public class ProfileService { if (req.getSchool() != null) profile.setSchool(req.getSchool()); if (req.getGraduationYear() != null) profile.setGraduationYear(req.getGraduationYear().shortValue()); if (req.getResumeUrl() != null) profile.setResumeUrl(req.getResumeUrl()); - if (req.getSkills() != null) profile.setSkillsJson(req.getSkills().toString()); + if (req.getSkills() != null) profile.setSkillsJson(toValidJson(req.getSkills())); return profileRepository.save(profile); } + /** + * 将技能列表序列化为合法的 JSON 数组字符串。 + * 注意:既不能直接用 {@code List#toString}(会得到 {@code [Java, Spring Boot]},非法 JSON), + * 也不能用原样字符(skills_json 为 MySQL JSON 列,非法 JSON 会写入失败并返回 500)。 + */ + private static String toValidJson(List skills) { + try { + return OBJECT_MAPPER.writeValueAsString(skills); + } catch (JsonProcessingException e) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + } + // ── 简历校验 ────────────────────────────────────────────── public void validateResumeFile(MultipartFile file) { @@ -179,6 +198,29 @@ public class ProfileService { return view; } + /** + * 将学生资料转换为公开响应 DTO(GET /profile/me)。 + * 未创建资料(profile 为 null)时返回空 DTO 对象,不自动创建数据库记录。 + */ + public StudentProfileResponse toResponse(StudentProfile profile) { + if (profile == null) { + return new StudentProfileResponse(); + } + return StudentProfileResponse.builder() + .id(profile.getId()) + .userId(profile.getUserId()) + .realName(profile.getRealName()) + .bio(profile.getBio()) + .location(profile.getLocation()) + .education(profile.getEducation()) + .school(profile.getSchool()) + .graduationYear(profile.getGraduationYear()) + .resumeUrl(profile.getResumeUrl()) + .skills(profile.getSkillsJson()) + .createdAt(profile.getCreatedAt()) + .build(); + } + public Map toResumeView(StudentProfile profile) { Map view = new HashMap<>(); view.put("resumeUrl", profile.getResumeUrl()); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileResponse.java new file mode 100644 index 0000000..dac769e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileResponse.java @@ -0,0 +1,69 @@ +package com.mydreamjob.profile.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 学生个人资料响应 DTO(GET /profile/me) + * + *

仅暴露当前登录学生自己的资料,字段与 {@code StudentProfile} 当前业务字段一致。 + * 空资料(尚未创建 {@code student_profiles} 记录)时返回空对象 {@code {}}(非 null), + * 且不会自动创建数据库记录。 + * + *

所有字段均可为空,null 字段不序列化(与 {@code ApiResponse} 一致的 + * {@code NON_NULL} 规则)。不返回密码、验证码、JWT、手机号等内部/敏感字段。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class StudentProfileResponse { + + @Schema(description = "学生资料 ID;有资料时返回", nullable = true, example = "1") + private Long id; + + @Schema(description = "当前登录用户 ID;有资料时返回", nullable = true, example = "2") + private Long userId; + + @Schema(description = "真实姓名,可为空", nullable = true, example = "张三") + private String realName; + + @Schema(description = "个人简介,可为空", nullable = true) + private String bio; + + @Schema(description = "所在地,可为空", nullable = true) + private String location; + + @Schema(description = "学历,可为空", nullable = true, example = "本科") + private String education; + + @Schema(description = "学校,可为空", nullable = true, example = "某某大学") + private String school; + + @Schema( + description = "毕业年份(四位年份),可为空;后端当前未实现年份范围校验,示例仅供展示,不代表存在范围限制", + nullable = true, example = "2026") + private Short graduationYear; + + @Schema( + description = "简历地址,可为空;值可能是相对存储路径或文件存储地址,不保证是完整 http/https URL。" + + "下载简历请使用 GET /api/v1/profile/me/resume,不要直接拼接本字段", + nullable = true, example = "/uploads/resumes/2/resume.pdf") + private String resumeUrl; + + @Schema( + description = "技能,可为空;当前为历史兼容的字符串(非数组,也不保证是合法 JSON)," + + "示例 \"[Java, Spring Boot]\" 仅表示普通字符串内容,前端不应无条件 JSON.parse", + nullable = true, example = "[Java, Spring Boot]") + private String skills; + + @Schema(description = "创建时间,可为空", nullable = true, format = "date-time", example = "2026-08-24T10:00:00") + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java new file mode 100644 index 0000000..067fa6d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java @@ -0,0 +1,123 @@ +package com.mydreamjob.safety; + +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +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.safety.dto.ReportDecisionRequest; +import com.mydreamjob.safety.dto.ReportResponse; +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 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.*; + +/** + * ADMIN 举报管理接口(V33)。仅 ADMIN;COMPANY/STUDENT/未登录分别 403/403/401。 + * + *

处理不自动封禁、不删除消息、不修改投递/面试/评分状态。决策仅影响举报单状态与留痕。

+ */ +@Tag(name = "举报管理", description = "ADMIN 查看与处理举报") +@RestController +@RequestMapping("/admin") +@RequiredArgsConstructor +public class AdminReportController { + + private final ReportService reportService; + private final UserRepository userRepository; + + @Operation(summary = "举报列表(ADMIN)", + description = "仅 ADMIN;支持 status/targetType/category/page/size 筛选;响应脱敏。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 ADMIN(2002/4001)") + }) + @GetMapping("/reports") + public ResponseEntity>> adminList( + @RequestParam(required = false) String status, + @RequestParam(required = false) String targetType, + @RequestParam(required = false) String category, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Long userId = requireAdmin(request); + Page result = reportService.adminList(status, targetType, category, page, size); + return ResponseEntity.ok(ApiResponse.ok(PageResponse.of( + result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()), + requestId(request))); + } + + @Operation(summary = "举报详情(ADMIN)", + description = "仅 ADMIN;可见脱敏证据、MESSAGE 脱敏内容、会话上下文最小信息与处置历史;" + + "不返回密码/JWT/手机号/邮箱/resumeUrl。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 ADMIN(2002/4001)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "举报不存在(9601)") + }) + @GetMapping("/reports/{id}") + public ResponseEntity> adminDetail(@PathVariable Long id, + HttpServletRequest request) { + requireAdmin(request); + return ResponseEntity.ok(ApiResponse.ok(reportService.adminDetail(id), requestId(request))); + } + + @Operation(summary = "举报处理决定(ADMIN)", + description = "仅 ADMIN;decision 仅 RESOLVED/REJECTED;REJECTED 必填 note;" + + "仅 PENDING 可处理,重复 409/9604,乐观锁冲突 409/9601-9605;" + + "处理后向举报人发送一条 REPORT_DECISION 通知(幂等)。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数错误(REJECTED 缺 note)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 ADMIN(2002/4001)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "举报不存在(9601)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "重复决定/非法流转(9604/9605)") + }) + @PostMapping("/reports/{id}/decision") + public ResponseEntity> decide( + @PathVariable Long id, @Valid @RequestBody ReportDecisionRequest req, + HttpServletRequest request) { + Long adminUserId = requireAdmin(request); + User admin = userRepository.findById(adminUserId).orElseThrow(); + String role = admin.getRoles() == null || admin.getRoles().isEmpty() + ? (admin.getRole() == null ? "ADMIN" : admin.getRole().name()) + : "ADMIN"; + ReportResponse data = reportService.decide(adminUserId, role, id, req, AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(data, requestId(request))); + } + + private Long requireAdmin(HttpServletRequest request) { + Long userId = resolveUserId(request); + User admin = userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + boolean isAdmin = admin.getRoles() != null + && admin.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN); + if (!isAdmin) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + return userId; + } + + 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 requestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java new file mode 100644 index 0000000..5dd597b --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java @@ -0,0 +1,92 @@ +package com.mydreamjob.safety; + +import com.mydreamjob.audit.AuditContext; +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.safety.dto.BlockRelationResponse; +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.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.*; + +/** + * 屏蔽接口(V33)。屏蔽仅影响站内信;不影响系统通知/投递/Offer/面试/评分/评价。 + * PUT/DELETE 幂等。 + */ +@Tag(name = "屏蔽", description = "用户屏蔽与解除屏蔽") +@RestController +@RequiredArgsConstructor +public class BlockController { + + private final BlockService blockService; + + @Operation(summary = "屏蔽用户", + description = "幂等:已屏蔽返回 200 不新增。屏蔽自己 400/9608;目标不存在 404/4001。" + + "默认:对外不泄露是谁屏蔽了谁,仅屏蔽站内信。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "屏蔽自己(9608)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "目标用户不存在(4001)") + }) + @PutMapping("/blocks/{blockedUserId}") + public ResponseEntity> block( + @PathVariable Long blockedUserId, HttpServletRequest request) { + Long userId = resolveUserId(request); + BlockRelationResponse data = blockService.block(userId, blockedUserId, AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(data, requestId(request))); + } + + @Operation(summary = "解除屏蔽", + description = "幂等:不存在时返回 200 不报错。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "屏蔽自己(9608)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录") + }) + @DeleteMapping("/blocks/{blockedUserId}") + public ResponseEntity> unblock( + @PathVariable Long blockedUserId, HttpServletRequest request) { + Long userId = resolveUserId(request); + blockService.unblock(userId, blockedUserId, AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(null, requestId(request))); + } + + @Operation(summary = "我的屏蔽列表", + description = "仅返回当前用户主动屏蔽的目标安全画像,不含对方联系方式/密码/简历地址。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录") + }) + @GetMapping("/blocks/me") + public ResponseEntity>> listMine( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Page result = blockService.listMine(userId, page, size); + return ResponseEntity.ok(ApiResponse.ok(PageResponse.of( + result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()), + requestId(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 requestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockRelation.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockRelation.java new file mode 100644 index 0000000..a4808d2 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockRelation.java @@ -0,0 +1,47 @@ +package com.mydreamjob.safety; + +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.time.LocalDateTime; + +/** + * 屏蔽关系(V33)。 + * + *

方向为 {@code blockerUser -> blockedUser},唯一约束保证同方向不重复。 + * 屏蔽仅影响站内信交互(禁止新建 DIRECT 会话、禁止发送新消息),不影响系统通知、 + * 投递、Offer、面试、评分与评价;历史消息保留可读不删除。

+ */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "block_relations") +@EntityListeners(AuditingEntityListener.class) +public class BlockRelation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "blocker_user_id", nullable = false) + private Long blockerUserId; + + @Column(name = "blocked_user_id", nullable = false) + private Long blockedUserId; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockRelationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockRelationRepository.java new file mode 100644 index 0000000..f0e64d3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockRelationRepository.java @@ -0,0 +1,23 @@ +package com.mydreamjob.safety; + +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.List; +import java.util.Optional; + +@Repository +public interface BlockRelationRepository extends JpaRepository { + + Optional findByBlockerUserIdAndBlockedUserId(Long blockerUserId, Long blockedUserId); + + boolean existsByBlockerUserIdAndBlockedUserId(Long blockerUserId, Long blockedUserId); + + /** 单方向是否存在(blocker 屏蔽 blocked) */ + List findByBlockerUserId(Long blockerUserId); + + /** 我的屏蔽列表(分页),仅当前用户主动屏蔽的 */ + Page findByBlockerUserIdOrderByCreatedAtDesc(Long blockerUserId, Pageable pageable); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockService.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockService.java new file mode 100644 index 0000000..33232b0 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockService.java @@ -0,0 +1,102 @@ +package com.mydreamjob.safety; + +import com.mydreamjob.audit.AuditContext; +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.safety.dto.BlockRelationResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.util.Optional; + +/** + * 屏蔽模块(V33)。 + * + *

屏蔽仅影响站内信交互;不影响系统通知、投递、Offer、面试、评分与评价。 + * PUT/DELETE 均幂等。不返回对方联系方式。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class BlockService { + + private static final int MAX_PAGE_SIZE = 50; + + private final BlockRelationRepository blockRepository; + private final UserRepository userRepository; + private final AuditService auditService; + + @Transactional + public BlockRelationResponse block(Long blockerUserId, Long blockedUserId, AuditContext ctx) { + if (blockerUserId.equals(blockedUserId)) { + throw new BusinessException(ErrorCode.CANNOT_BLOCK_SELF); + } + User blocked = userRepository.findById(blockedUserId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + Optional existing = blockRepository + .findByBlockerUserIdAndBlockedUserId(blockerUserId, blockedUserId); + if (existing.isPresent()) { + // 幂等:已屏蔽 → 200 返回现有关系,不新增 + return toResponse(existing.get(), blocked.getNickname()); + } + BlockRelation saved; + try { + saved = blockRepository.save(BlockRelation.builder() + .blockerUserId(blockerUserId).blockedUserId(blockedUserId).build()); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // 并发下唯一约束兜底:返回现有关系 + BlockRelation race = blockRepository + .findByBlockerUserIdAndBlockedUserId(blockerUserId, blockedUserId).orElseThrow(); + return toResponse(race, blocked.getNickname()); + } + auditService.log(blockerUserId, "BLOCK_CREATED", "BLOCK", saved.getId(), + "UserId " + blockedUserId + " blocked", ctx); + return toResponse(saved, blocked.getNickname()); + } + + @Transactional + public void unblock(Long blockerUserId, Long blockedUserId, AuditContext ctx) { + if (blockerUserId.equals(blockedUserId)) { + throw new BusinessException(ErrorCode.CANNOT_BLOCK_SELF); + } + Optional existing = blockRepository + .findByBlockerUserIdAndBlockedUserId(blockerUserId, blockedUserId); + if (existing.isPresent()) { + Long id = existing.get().getId(); + blockRepository.delete(existing.get()); + auditService.log(blockerUserId, "BLOCK_REMOVED", "BLOCK", id, + "UserId " + blockedUserId + " unblocked", ctx); + } + // 不存在 → 幂等返回 200,不报错 + } + + public Page listMine(Long blockerUserId, int page, int size) { + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + return blockRepository + .findByBlockerUserIdOrderByCreatedAtDesc(blockerUserId, PageRequest.of(page, size)) + .map(r -> toResponse(r, resolveNickname(r.getBlockedUserId()))); + } + + private String resolveNickname(Long blockedUserId) { + return userRepository.findById(blockedUserId).map(User::getNickname).orElse(null); + } + + private BlockRelationResponse toResponse(BlockRelation r, String nickname) { + return BlockRelationResponse.builder() + .id(r.getId()) + .blockedUserId(r.getBlockedUserId()) + .nickname(nickname) + .createdAt(r.getCreatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/Report.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/Report.java new file mode 100644 index 0000000..856979c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/Report.java @@ -0,0 +1,88 @@ +package com.mydreamjob.safety; + +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.time.LocalDateTime; + +/** + * 举报单(V33)。 + * + *

{@code targetType} 仅支持 {@code USER} / {@code MESSAGE}。当为 USER 时由客户端传 + * {@code targetUserId};当为 MESSAGE 时由客户端传 {@code messageId},{@code reportedUserId} + * 一律由服务端从消息真实发送者推导,禁止客户端伪造。

+ * + *

{@code activeKey} 用于防止同一个人对同一目标同一分类的重复待处理举报在并发下被 + * 重复创建(全局唯一)。{@code idempotencyKey} 用于幂等重放(按 reporter 唯一)。

+ */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "reports") +@EntityListeners(AuditingEntityListener.class) +public class Report { + + public static final String TARGET_USER = "USER"; + public static final String TARGET_MESSAGE = "MESSAGE"; + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_RESOLVED = "RESOLVED"; + public static final String STATUS_REJECTED = "REJECTED"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "reporter_user_id", nullable = false) + private Long reporterUserId; + + @Column(name = "target_type", nullable = false, length = 20) + private String targetType; + + @Column(name = "target_user_id") + private Long targetUserId; + + @Column(name = "message_id") + private Long messageId; + + @Column(name = "reported_user_id", nullable = false) + private Long reportedUserId; + + @Column(nullable = false, length = 40) + private String category; + + @Column(nullable = false, length = 1000) + private String reason; + + @Column(name = "evidence_urls", columnDefinition = "TEXT") + private String evidenceUrls; + + @Column(nullable = false, length = 20) + @Builder.Default + private String status = STATUS_PENDING; + + @Column(name = "idempotency_key", length = 64) + private String idempotencyKey; + + @Column(name = "active_key", length = 255) + private String activeKey; + + @Version + @Column(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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java new file mode 100644 index 0000000..c2d047c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java @@ -0,0 +1,114 @@ +package com.mydreamjob.safety; + +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +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.safety.dto.CreateReportRequest; +import com.mydreamjob.safety.dto.ReportResponse; +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 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.*; + +/** + * 举报接口(V33)。 + * + *

身份仅取自 JWT,不接受 reporterUserId。举报人本人或 ADMIN 可查看详情; + * 被举报人无法看到举报人身份。未登录 401。

+ */ +@Tag(name = "举报", description = "用户举报与查询") +@RestController +@RequiredArgsConstructor +public class ReportController { + + private final ReportService reportService; + private final UserRepository userRepository; + + @Operation(summary = "提交举报", + description = "targetType=USER 需 targetUserId;targetType=MESSAGE 需 messageId(服务端推导发送者为被举报人)。" + + "幂等键重放:同键同请求返回原单 200,同键不同请求 409/9606;待处理重复 409/9603;" + + "举报自己 400/9602;限流 429/9609。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数错误(1001/9602/9607)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无权访问该消息(2002)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "目标用户不存在(4001)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "重复举报/幂等冲突(9603/9606)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "429", description = "举报限流(9609)") + }) + @PostMapping("/reports") + public ResponseEntity> create( + @Valid @RequestBody CreateReportRequest req, HttpServletRequest request) { + Long userId = resolveUserId(request); + ReportResponse data = reportService.create(userId, req, AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(data, requestId(request))); + } + + /** /reports/me 必须在 /reports/{id} 之前注册(路由回归测试覆盖) */ + @Operation(summary = "我的举报列表", + description = "仅返回当前用户创建的举报;支持 status/page/size;不含其他举报人信息、联系方式、消息全文。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录") + }) + @GetMapping("/reports/me") + public ResponseEntity>> listMine( + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Long userId = resolveUserId(request); + Page result = reportService.listMine(userId, status, page, size); + return ResponseEntity.ok(ApiResponse.ok(PageResponse.of( + result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()), + requestId(request))); + } + + @Operation(summary = "我的举报详情", + description = "当前举报人本人或 ADMIN 可查看;其他用户 403/2002。" + + "ADMIN 可见处置历史与更完整画像;被举报人无法查看举报人身份。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非举报人且非 ADMIN(2002)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "举报不存在(9601)") + }) + @GetMapping("/reports/{id}") + public ResponseEntity> getDetail( + @PathVariable Long id, HttpServletRequest request) { + Long userId = resolveUserId(request); + ReportResponse data = reportService.getDetail(userId, id, isAdmin(userId)); + return ResponseEntity.ok(ApiResponse.ok(data, requestId(request))); + } + + private boolean isAdmin(Long userId) { + return userRepository.findById(userId) + .map(u -> u.getRoles() != null + && u.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN)) + .orElse(false); + } + + 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 requestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportDecisionHistory.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportDecisionHistory.java new file mode 100644 index 0000000..b2641e5 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportDecisionHistory.java @@ -0,0 +1,54 @@ +package com.mydreamjob.safety; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 举报处置流转历史(V33)。 + * + *

记录举报 {@code PENDING -> RESOLVED / REJECTED} 的每次处置,供 ADMIN 与审计追溯。 + * 仅从头(PENDING)到终态(RESOLVED / REJECTED)的一条记录。

+ */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "report_decision_history") +public class ReportDecisionHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "report_id", nullable = false) + private Long reportId; + + @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; + + @Column(name = "actor_role", nullable = false, length = 30) + private String actorRole; + + @Column(columnDefinition = "TEXT") + private String note; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + void prePersist() { + if (createdAt == null) createdAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportDecisionHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportDecisionHistoryRepository.java new file mode 100644 index 0000000..1855701 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportDecisionHistoryRepository.java @@ -0,0 +1,14 @@ +package com.mydreamjob.safety; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ReportDecisionHistoryRepository extends JpaRepository { + + List findByReportIdOrderByCreatedAtDesc(Long reportId); + + boolean existsByReportId(Long reportId); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportRateLimiter.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportRateLimiter.java new file mode 100644 index 0000000..23b630f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportRateLimiter.java @@ -0,0 +1,51 @@ +package com.mydreamjob.safety; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * 举报限流(V33)。仅限制 POST /reports,使用现有 Redis,不新增数据库表。 + * + *

Key:{@code report:rate:{userId}:{hour}}(yyyyMMddHH);每用户每小时上限默认 10 次。 + * 超限返回 {@code REPORT_RATE_LIMITED/9609}。不影响屏蔽、查询与 ADMIN 处理接口。

+ */ +@Component +public class ReportRateLimiter { + + private static final String KEY_PREFIX = "report:rate:"; + private static final DateTimeFormatter HOUR_FMT = DateTimeFormatter.ofPattern("yyyyMMddHH"); + + private final RedisTemplate redisTemplate; + + @Value("${app.report.rate-limit:10}") + private int limitPerHour; + + public ReportRateLimiter(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + /** + * 判断当前用户是否允许继续提交举报。 + * + * @return true 允许;false 已超限 + */ + public boolean isAllowed(Long userId, LocalDateTime now) { + if (userId == null) return true; + String key = KEY_PREFIX + userId + ":" + HOUR_FMT.format(now); + try { + Long count = redisTemplate.opsForValue().increment(key); + if (count != null && count == 1L) { + // 首次命中:设置整小时过期,保证下一小时重新计数 + redisTemplate.expire(key, java.time.Duration.ofHours(1)); + } + return count == null || count <= limitPerHour; + } catch (Exception e) { + // Redis 不可用时放行,避免限流失效阻断主流程 + return true; + } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportRepository.java new file mode 100644 index 0000000..625686d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportRepository.java @@ -0,0 +1,51 @@ +package com.mydreamjob.safety; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ReportRepository extends JpaRepository, + JpaSpecificationExecutor { + + /** ADMIN 列表多条件筛选(status / targetType / category 可空组合) */ + Page findAll(Specification spec, Pageable pageable); + + /** 幂等:同一举报人 + 幂等键取原记录 */ + Optional findByReporterUserIdAndIdempotencyKey(Long reporterUserId, String idempotencyKey); + + /** active_key 全局唯一,用于并发防重复创建待处理举报;任一返回即视为已存在 */ + Optional findByActiveKey(String activeKey); + + // ── 待处理重复检查(active 语义,服务层在并发下由唯一约束兜底)── + + /** USER 类型:是否已有同一举报人对同一被举报人同一分类的 PENDING 举报 */ + boolean existsByReporterUserIdAndReportedUserIdAndCategoryAndTargetTypeAndStatus( + Long reporterUserId, Long reportedUserId, String category, String targetType, String status); + + /** MESSAGE 类型:是否已有同一举报人对同一条消息的 PENDING 举报 */ + boolean existsByReporterUserIdAndMessageIdAndStatus(Long reporterUserId, Long messageId, String status); + + // ── 我的举报列表 ── + + Page findByReporterUserIdOrderByCreatedAtDesc(Long reporterUserId, Pageable pageable); + + Page findByReporterUserIdAndStatusOrderByCreatedAtDesc( + Long reporterUserId, String status, Pageable pageable); + + // ── ADMIN 举报列表 ── + + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + + Page findByStatusOrderByCreatedAtDesc(String status, Pageable pageable); + + Page findByTargetTypeOrderByCreatedAtDesc(String targetType, Pageable pageable); + + Page findByStatusAndTargetTypeOrderByCreatedAtDesc( + String status, String targetType, Pageable pageable); +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportService.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportService.java new file mode 100644 index 0000000..d5f013c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportService.java @@ -0,0 +1,437 @@ +package com.mydreamjob.safety; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.audit.SecurityAuditService; +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.messaging.ConversationMemberRepository; +import com.mydreamjob.messaging.Message; +import com.mydreamjob.messaging.MessageRepository; +import com.mydreamjob.notification.NotificationService; +import com.mydreamjob.safety.dto.CreateReportRequest; +import com.mydreamjob.safety.dto.ReportDecisionRequest; +import com.mydreamjob.safety.dto.ReportResponse; +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.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * 举报模块(V33)。 + * + *

身份仅取自 JWT;被举报人由服务端推导或校验,不接受客户端注入 reportedUserId。 + * 提供幂等(idempotency_key)、并发防重复(active_key 全局唯一 + 事务)、 + * ADMIN 处置(@Version 乐观锁)能力。举报处理不自动封禁、不删除消息、 + * 不修改投递/面试/评分/评价状态。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ReportService { + + private static final int MAX_PAGE_SIZE = 50; + private static final int MESSAGE_PREVIEW_MAX = 80; + private static final String REPORT_DECISION_NOTIFICATION_TYPE = "REPORT_DECISION"; + private static final String NOTIFICATION_BUSINESS_TYPE = "REPORT"; + + private final ReportRepository reportRepository; + private final ReportDecisionHistoryRepository historyRepository; + private final UserRepository userRepository; + private final MessageRepository messageRepository; + private final ConversationMemberRepository memberRepository; + private final NotificationService notificationService; + private final AuditService auditService; + private final SecurityAuditService securityAuditService; + private final ReportRateLimiter rateLimiter; + private final ObjectMapper objectMapper; + + // ─────────────────────────── 举报创建 ─────────────────────────── + + /** + * 提交举报。目标 USER 时校验 targetUserId;目标 MESSAGE 时校验 messageId 并推导 sentder。 + * 幂等 + active_key 并发防重复。 + */ + @Transactional + public ReportResponse create(Long reporterUserId, CreateReportRequest req, AuditContext ctx) { + String targetType = normalizeTargetType(req.getTargetType()); + if (!Report.TARGET_USER.equals(targetType) && !Report.TARGET_MESSAGE.equals(targetType)) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "targetType must be USER or MESSAGE"); + } + validateEvidence(req.getEvidenceUrls()); + + LocalDateTime now = LocalDateTime.now(); + + // 限流:仅 POST /reports。拒绝型安全审计以独立事务落库,不受本次回滚影响 + if (!rateLimiter.isAllowed(reporterUserId, now)) { + securityAuditService.logRejected(reporterUserId, "REPORT_RATE_LIMITED", "REPORT", null, + "Report rate limited", ctx); + throw new BusinessException(ErrorCode.REPORT_RATE_LIMITED); + } + + // 解析目标与被举报人(同一事务内校验 messageId、reportedUserId、会话参与关系) + Long targetUserId = null; + Long messageId = null; + Long reportedUserId; + Long messageConversation = null; + if (Report.TARGET_USER.equals(targetType)) { + if (req.getTargetUserId() == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "targetUserId is required for USER target"); + } + if (req.getTargetUserId().equals(reporterUserId)) { + throw new BusinessException(ErrorCode.CANNOT_REPORT_SELF); + } + if (!userRepository.existsById(req.getTargetUserId())) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND); + } + targetUserId = req.getTargetUserId(); + reportedUserId = req.getTargetUserId(); + } else { // MESSAGE + if (req.getMessageId() == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "messageId is required for MESSAGE target"); + } + Message msg = messageRepository.findById(req.getMessageId()) + .orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN, "message cannot be accessed")); + // 当前用户必须是该会话参与者;无权访问时不泄露消息是否存在 + if (!memberRepository.existsByConversationIdAndUserId(msg.getConversationId(), reporterUserId)) { + throw new BusinessException(ErrorCode.FORBIDDEN, "message cannot be accessed"); + } + Long senderId = msg.getSenderUserId(); + if (senderId.equals(reporterUserId)) { + throw new BusinessException(ErrorCode.CANNOT_REPORT_SELF); + } + messageId = req.getMessageId(); + messageConversation = msg.getConversationId(); + reportedUserId = senderId; + } + + // 幂等:同一 reporter + idempotencyKey + String idem = req.getIdempotencyKey(); + if (idem != null && !idem.isBlank()) { + Optional existing = reportRepository + .findByReporterUserIdAndIdempotencyKey(reporterUserId, idem); + if (existing.isPresent()) { + if (sameBody(existing.get(), targetType, targetUserId, messageId, req.getCategory(), req.getReason())) { + return toResponse(existing.get(), reporterUserId, false); + } + throw new BusinessException(ErrorCode.REPORT_IDEMPOTENCY_CONFLICT); + } + } + + // 待处理重复:同一人对同一目标同一分类已存在 PENDING + if (Report.TARGET_USER.equals(targetType)) { + if (reportRepository.existsByReporterUserIdAndReportedUserIdAndCategoryAndTargetTypeAndStatus( + reporterUserId, reportedUserId, req.getCategory(), Report.TARGET_USER, Report.STATUS_PENDING)) { + throw new BusinessException(ErrorCode.REPORT_DUPLICATE); + } + } else { + if (reportRepository.existsByReporterUserIdAndMessageIdAndStatus( + reporterUserId, messageId, Report.STATUS_PENDING)) { + throw new BusinessException(ErrorCode.REPORT_DUPLICATE); + } + } + + // active_key 防并发重复:同一 reporter+target+category 只能一条待处理 + String targetDesc = Report.TARGET_USER.equals(targetType) + ? "U" + targetUserId : "M" + messageId; + String activeKey = reporterUserId + ":" + targetType + ":" + targetDesc + ":" + req.getCategory(); + + Report report = Report.builder() + .reporterUserId(reporterUserId) + .targetType(targetType) + .targetUserId(Report.TARGET_USER.equals(targetType) ? targetUserId : null) + .messageId(Report.TARGET_MESSAGE.equals(targetType) ? messageId : null) + .reportedUserId(reportedUserId) + .category(req.getCategory()) + .reason(req.getReason()) + .evidenceUrls(toJson(req.getEvidenceUrls())) + .status(Report.STATUS_PENDING) + .idempotencyKey(idem != null && !idem.isBlank() ? idem : null) + .activeKey(activeKey) + .build(); + + try { + reportRepository.saveAndFlush(report); + } catch (DataIntegrityViolationException e) { + // 并发下唯一约束兜底: + // - 若命中幂等键且有现成记录,返回原记录 + // - 否则视为待处理重复 → 9603 + if (idem != null && !idem.isBlank()) { + Optional existing = reportRepository + .findByReporterUserIdAndIdempotencyKey(reporterUserId, idem); + if (existing.isPresent()) { + if (sameBody(existing.get(), targetType, targetUserId, messageId, req.getCategory(), req.getReason())) { + return toResponse(existing.get(), reporterUserId, false); + } + throw new BusinessException(ErrorCode.REPORT_IDEMPOTENCY_CONFLICT); + } + } + throw new BusinessException(ErrorCode.REPORT_DUPLICATE); + } + + auditService.log(reporterUserId, "REPORT_CREATED", "REPORT", report.getId(), + "ReportId " + report.getId() + ", category=" + report.getCategory() + + ", targetType=" + targetType + ", targetUserId=" + reportedUserId, + ctx); + return toResponse(report, reporterUserId, false); + } + + // ─────────────────────────── 我的举报 ─────────────────────────── + + public Page listMine(Long reporterUserId, String status, int page, int size) { + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + PageRequest pageable = PageRequest.of(page, size); + Page result; + if (status != null && !status.isBlank()) { + result = reportRepository + .findByReporterUserIdAndStatusOrderByCreatedAtDesc(reporterUserId, status, pageable); + } else { + result = reportRepository.findByReporterUserIdOrderByCreatedAtDesc(reporterUserId, pageable); + } + return result.map(r -> toResponse(r, reporterUserId, false)); + } + + /** + * 举报详情。当前举报人本人或 ADMIN 可查看;其他人 403。 + */ + public ReportResponse getDetail(Long viewerId, Long reportId, boolean isAdmin) { + Report r = reportRepository.findById(reportId) + .orElseThrow(() -> new BusinessException(ErrorCode.REPORT_NOT_FOUND)); + boolean owner = r.getReporterUserId().equals(viewerId); + if (!owner && !isAdmin) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + return toResponse(r, viewerId, isAdmin); + } + + // ─────────────────────────── ADMIN ─────────────────────────── + + public Page adminList(String status, String targetType, String category, int page, int size) { + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + Specification spec = Specification.where(null); + if (status != null && !status.isBlank()) { + spec = spec.and((root, q, cb) -> cb.equal(root.get("status"), status)); + } + if (targetType != null && !targetType.isBlank()) { + spec = spec.and((root, q, cb) -> cb.equal(root.get("targetType"), targetType)); + } + if (category != null && !category.isBlank()) { + spec = spec.and((root, q, cb) -> cb.equal(root.get("category"), category)); + } + return reportRepository.findAll(spec, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))) + .map(r -> toResponse(r, null, true)); + } + + public ReportResponse adminDetail(Long reportId) { + Report r = reportRepository.findById(reportId) + .orElseThrow(() -> new BusinessException(ErrorCode.REPORT_NOT_FOUND)); + return toResponse(r, null, true); + } + + /** + * ADMIN 处理决定。仅 PENDING → RESOLVED / REJECTED;@Version 乐观锁防并发双决定。 + * 处理完成后向举报人发送一条 REPORT_DECISION 通知(幂等)。 + */ + @Transactional + public ReportResponse decide(Long actorUserId, String actorRole, Long reportId, + ReportDecisionRequest req, AuditContext ctx) { + Report r = reportRepository.findById(reportId) + .orElseThrow(() -> new BusinessException(ErrorCode.REPORT_NOT_FOUND)); + if (!Report.STATUS_PENDING.equals(r.getStatus())) { + throw new BusinessException(ErrorCode.REPORT_ALREADY_DECIDED); + } + String decision = req.getDecision(); + boolean rejected = Report.STATUS_REJECTED.equals(decision); + if (rejected && (req.getNote() == null || req.getNote().isBlank())) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "note is required for rejected decision"); + } + if (!Report.STATUS_RESOLVED.equals(decision) && !rejected) { + throw new BusinessException(ErrorCode.REPORT_INVALID_TRANSITION); + } + + String fromStatus = r.getStatus(); + try { + r.setStatus(decision); + r.setActiveKey(null); // 释放 active_key,允许后续重新举报 + reportRepository.saveAndFlush(r); + } catch (OptimisticLockingFailureException e) { + // 乐观锁冲突:双 ADMIN 并发处置 → 409 + throw new BusinessException(ErrorCode.REPORT_ALREADY_DECIDED); + } + + // 处置历史 + historyRepository.save(ReportDecisionHistory.builder() + .reportId(reportId) + .fromStatus(fromStatus) + .toStatus(decision) + .actorUserId(actorUserId) + .actorRole(actorRole) + .note(req.getNote()) + .build()); + + // 通知举报人(幂等:report-decision-{reportId}) + try { + notificationService.create(r.getReporterUserId(), REPORT_DECISION_NOTIFICATION_TYPE, + "举报处理结果通知", + "您对举报单 #" + reportId + " 的举报已处理:" + decision, + NOTIFICATION_BUSINESS_TYPE, reportId, + "report-decision-" + reportId); + } catch (Exception e) { + log.warn("Failed to notify reporter for report={}: {}", reportId, e.getMessage()); + } + + auditService.log(actorUserId, "REPORT_DECIDED", "REPORT", reportId, + "ReportId " + reportId + " decided as " + decision, ctx); + return toResponse(r, r.getReporterUserId(), true); + } + + // ─────────────────────────── 工具 ─────────────────────────── + + private String normalizeTargetType(String targetType) { + return targetType == null ? null : targetType.trim().toUpperCase(); + } + + private void validateEvidence(List urls) { + if (urls == null || urls.isEmpty()) return; + if (urls.size() > 5) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "at most 5 evidence urls"); + } + for (String url : urls) { + if (url == null || url.isBlank()) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "evidence url cannot be blank"); + } + if (url.length() > 500) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "evidence url must be at most 500 chars"); + } + // 仅允许 http/https;不由后端抓取,避免 SSRF + if (!url.startsWith("http://") && !url.startsWith("https://")) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "evidence url must be http(s)"); + } + } + } + + private boolean sameBody(Report r, String targetType, Long targetUserId, Long messageId, + String category, String reason) { + boolean sameTarget = Report.TARGET_USER.equals(targetType) + ? java.util.Objects.equals(targetUserId, r.getTargetUserId()) + : java.util.Objects.equals(messageId, r.getMessageId()); + return sameTarget + && java.util.Objects.equals(category, r.getCategory()) + && java.util.Objects.equals(reason, r.getReason()); + } + + private String toJson(List urls) { + if (urls == null || urls.isEmpty()) return null; + try { + return objectMapper.writeValueAsString(urls); + } catch (JsonProcessingException e) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid evidence urls"); + } + } + + private List fromJson(String json) { + if (json == null || json.isBlank()) return null; + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (JsonProcessingException e) { + return null; + } + } + + private ReportResponse toResponse(Report r, Long viewerId, boolean isAdmin) { + ReportResponse.ReportedUser reportedUser = resolveReportedUser(r.getReportedUserId()); + List evidence = fromJson(r.getEvidenceUrls()); + + String messagePreview = null; + if (Report.TARGET_MESSAGE.equals(r.getTargetType()) && r.getMessageId() != null + && (isAdmin || r.getReporterUserId().equals(viewerId))) { + messagePreview = loadMessagePreview(r.getMessageId()); + } + + List history = isAdmin + ? historyRepository.findByReportIdOrderByCreatedAtDesc(r.getId()) : List.of(); + + ReportResponse.ReportResponseBuilder builder = ReportResponse.builder() + .id(r.getId()) + .targetType(r.getTargetType()) + .targetUserId(r.getTargetUserId()) + .messageId(r.getMessageId()) + .reportedUserId(r.getReportedUserId()) + .category(r.getCategory()) + .reason(r.getReason()) + .evidenceUrls(evidence) + .status(r.getStatus()) + .createdAt(r.getCreatedAt()) + .updatedAt(r.getUpdatedAt()) + .messagePreview(messagePreview) + .reportedUser(reportedUser); + + // 终态时补充处置信息(来自历史记录) + if (Report.STATUS_RESOLVED.equals(r.getStatus()) || Report.STATUS_REJECTED.equals(r.getStatus())) { + ReportDecisionHistory latest = history.isEmpty() + ? historyRepository.findByReportIdOrderByCreatedAtDesc(r.getId()).stream().findFirst().orElse(null) + : history.get(0); + if (latest != null) { + builder.decision(latest.getToStatus()); + builder.decisionNote(latest.getNote()); + builder.decidedAt(latest.getCreatedAt()); + } + } + if (isAdmin && !history.isEmpty()) { + List items = history.stream().map(h -> + ReportResponse.DecisionHistoryItem.builder() + .id(h.getId()) + .fromStatus(h.getFromStatus()) + .toStatus(h.getToStatus()) + .actorUserId(h.getActorUserId()) + .actorRole(h.getActorRole()) + .note(h.getNote()) + .createdAt(h.getCreatedAt()) + .build()).toList(); + builder.history(items); + } + return builder.build(); + } + + private ReportResponse.ReportedUser resolveReportedUser(Long userId) { + return userRepository.findById(userId).map(u -> + ReportResponse.ReportedUser.builder() + .userId(u.getId()) + .nickname(u.getNickname()) + .role(u.getRoles() == null || u.getRoles().isEmpty() + ? (u.getRole() == null ? null : u.getRole().name()) + : u.getRoles().iterator().next().name()) + .build()) + .orElse(null); + } + + private String loadMessagePreview(Long messageId) { + return messageRepository.findById(messageId) + .map(m -> { + String content = m.getContent(); + if (content == null) return null; + return content.length() <= MESSAGE_PREVIEW_MAX + ? content : content.substring(0, MESSAGE_PREVIEW_MAX) + "..."; + }) + .orElse(null); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/dto/BlockRelationResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/dto/BlockRelationResponse.java new file mode 100644 index 0000000..9689da3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/dto/BlockRelationResponse.java @@ -0,0 +1,22 @@ +package com.mydreamjob.safety.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 屏蔽关系响应(V33)。仅返回当前用户主动屏蔽的目标用户安全画像, + * 不含对方 phone / email / 密码 / 简历地址。 + */ +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BlockRelationResponse { + + private Long id; + private Long blockedUserId; + private String nickname; + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/dto/CreateReportRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/dto/CreateReportRequest.java new file mode 100644 index 0000000..d8e289d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/dto/CreateReportRequest.java @@ -0,0 +1,50 @@ +package com.mydreamjob.safety.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +/** + * 提交举报请求(V33)。 + * + *

{@code targetType=USER} 必填 {@code targetUserId};{@code targetType=MESSAGE} 必填 + * {@code messageId}。被举报人 {@code reportedUserId} 一律由服务端推导,不接受客户端注入。 + * 身份仅取自 JWT,不接受 reporterUserId 参数。

+ */ +@Data +@Builder +@Schema(description = "提交举报请求") +public class CreateReportRequest { + + @NotBlank(message = "targetType is required") + @Pattern(regexp = "USER|MESSAGE", message = "targetType must be USER or MESSAGE") + @Schema(description = "举报目标类型:USER / MESSAGE", example = "USER") + private String targetType; + + @Schema(description = "目标用户ID(targetType=USER 时必填)", example = "5") + private Long targetUserId; + + @Schema(description = "目标消息ID(targetType=MESSAGE 时必填)", example = "101") + private Long messageId; + + @NotBlank(message = "category is required") + @Size(max = 40, message = "category must be at most 40 chars") + @Schema(description = "举报分类", example = "HARASSMENT") + private String category; + + @NotBlank(message = "reason is required") + @Size(min = 1, max = 1000, message = "reason must be 1-1000 chars") + @Schema(description = "举报理由", example = "对方持续发送骚扰消息") + private String reason; + + @Size(max = 5, message = "at most 5 evidence urls") + @Schema(description = "证据URL列表(最多5个,仅 http/https,最长500字符)") + private List<@Size(max = 500, message = "each evidence url must be at most 500 chars") String> evidenceUrls; + + @Size(max = 64, message = "idempotencyKey must be at most 64 chars") + @Schema(description = "幂等键(可选,最长64字符)") + private String idempotencyKey; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/dto/ReportDecisionRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/dto/ReportDecisionRequest.java new file mode 100644 index 0000000..4fb6442 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/dto/ReportDecisionRequest.java @@ -0,0 +1,27 @@ +package com.mydreamjob.safety.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * ADMIN 举报处理决定请求(V33)。 + * + *

{@code decision} 仅允许 RESOLVED / REJECTED;REJECTED 必须填写 {@code note}。 + * 处理不自动封禁、不删除消息、不修改投递/面试/评分状态。

+ */ +@Data +@Schema(description = "举报处理决定请求") +public class ReportDecisionRequest { + + @NotBlank(message = "decision is required") + @Pattern(regexp = "RESOLVED|REJECTED", message = "decision must be RESOLVED or REJECTED") + @Schema(description = "处理决定:RESOLVED / REJECTED", example = "RESOLVED") + private String decision; + + @Size(max = 1000, message = "note must be at most 1000 chars") + @Schema(description = "处理备注(REJECTED 时必填)") + private String note; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/safety/dto/ReportResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/safety/dto/ReportResponse.java new file mode 100644 index 0000000..6be2a64 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/safety/dto/ReportResponse.java @@ -0,0 +1,71 @@ +package com.mydreamjob.safety.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 举报单响应(V33)。脱敏: + *
    + *
  • 不含 reporterUserId(举报人内部ID仅在非 ADMIN 场景不展示,且身份本身来自 JWT);
  • + *
  • 不含被举报人 phone / email / resumeUrl / 密码 / JWT;
  • + *
  • MESSAGE 举报仅返回脱敏 {@code messagePreview},不返回对话内其他敏感内容。
  • + *
+ */ +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ReportResponse { + + private Long id; + private String targetType; + private Long targetUserId; + private Long messageId; + private Long reportedUserId; + private String category; + private String reason; + + @JsonInclude(JsonInclude.Include.ALWAYS) + private List evidenceUrls; + + private String status; + private String decision; + private String decisionNote; + private LocalDateTime decidedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + /** MESSAGE 举报的脱敏消息预览,仅举报人(会话参与者)或 ADMIN 可见 */ + private String messagePreview; + + /** 被举报人安全画像(含脱敏后的角色/昵称),不含联系方式 */ + private ReportedUser reportedUser; + + /** 处置历史(仅 ADMIN 详情返回) */ + @JsonInclude(JsonInclude.Include.ALWAYS) + private List history; + + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ReportedUser { + private Long userId; + private String nickname; + private String role; + } + + @Data + @Builder + public static class DecisionHistoryItem { + private Long id; + private String fromStatus; + private String toStatus; + private Long actorUserId; + private String actorRole; + private String note; + private LocalDateTime createdAt; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/resources/application.yml b/mydreamjob-server/src/main/resources/application.yml index 96348e2..213f600 100644 --- a/mydreamjob-server/src/main/resources/application.yml +++ b/mydreamjob-server/src/main/resources/application.yml @@ -89,6 +89,14 @@ app: core-pool-size: ${RESUME_EXPORT_CORE_POOL:2} max-pool-size: ${RESUME_EXPORT_MAX_POOL:4} queue-capacity: ${RESUME_EXPORT_QUEUE_CAPACITY:100} + data-export: + core-pool-size: ${DATA_EXPORT_CORE_POOL:2} + max-pool-size: ${DATA_EXPORT_MAX_POOL:4} + queue-capacity: ${DATA_EXPORT_QUEUE_CAPACITY:100} + + data-export: + dir: ${DATA_EXPORT_DIR:./data-exports} + file-ttl-hours: ${DATA_EXPORT_TTL_HOURS:24} sms: mock: @@ -121,6 +129,10 @@ app: header-name: X-Request-Id generate-on-missing: true + deletion: + cooling-days: ${DELETION_COOLING_DAYS:30} + phone-hash-secret: ${DELETION_PHONE_HASH_SECRET:mydreamjob-deletion-dev-secret-not-for-prod} + management: endpoints: web: diff --git a/mydreamjob-server/src/main/resources/db/migration/V24__candidate_scoring.sql b/mydreamjob-server/src/main/resources/db/migration/V24__candidate_scoring.sql new file mode 100644 index 0000000..5bd4ae0 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V24__candidate_scoring.sql @@ -0,0 +1,25 @@ +-- V24__candidate_scoring.sql +-- 求职者评分体系:候选人生成维度评分快照(评分基础设施写入,不修改业务表) + +CREATE TABLE candidate_dimension_scores ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + student_user_id BIGINT NOT NULL, + dimension VARCHAR(20) NOT NULL, + period VARCHAR(7) NOT NULL, + score DECIMAL(5,2), + status VARCHAR(30) NOT NULL, + sub_scores_json TEXT, + weight DECIMAL(6,2) NOT NULL DEFAULT 12.50, + rules_version VARCHAR(40) NOT NULL, + calculated_at DATETIME NOT NULL, + version BIGINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_candidate_dim_scores UNIQUE (student_user_id, dimension, period), + CONSTRAINT ck_cds_score CHECK (score IS NULL OR (score >= 0 AND score <= 100)), + CONSTRAINT ck_cds_dimension CHECK (dimension IN ('EDUCATION','INTERNSHIP','ABILITY','COMPLIANCE','STABILITY','GROWTH','INTEGRITY','ACTIVITY')), + CONSTRAINT ck_cds_status CHECK (status IN ('OK','ESTIMATED','INSUFFICIENT_DATA','PENDING')), + INDEX idx_cds_student_period (student_user_id, period), + INDEX idx_cds_dimension_period (dimension, period), + CONSTRAINT fk_cds_student FOREIGN KEY (student_user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mydreamjob-server/src/main/resources/db/migration/V25__candidate_scoring_governance.sql b/mydreamjob-server/src/main/resources/db/migration/V25__candidate_scoring_governance.sql new file mode 100644 index 0000000..8727c43 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V25__candidate_scoring_governance.sql @@ -0,0 +1,77 @@ +-- V25__candidate_scoring_governance.sql +-- 评分治理基础模块:申诉、审核、回滚、审计 + +-- 申诉主表 +CREATE TABLE candidate_scoring_appeals ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + candidate_user_id BIGINT NOT NULL, + dimension VARCHAR(20) NOT NULL, + period VARCHAR(7) NOT NULL, + status VARCHAR(20) NOT NULL, + reason VARCHAR(500) NOT NULL, + decision VARCHAR(20), + decision_reason VARCHAR(500), + reviewer_id BIGINT, + reviewed_at DATETIME, + rollback_at DATETIME, + score_at_appeal DECIMAL(5,2), + snapshot_version_at_appeal BIGINT, + rules_version VARCHAR(40) NOT NULL, + idempotency_key VARCHAR(64), + active_key VARCHAR(64), + version BIGINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_csa_idem UNIQUE (idempotency_key), + CONSTRAINT uk_csa_active UNIQUE (active_key), + CONSTRAINT ck_csa_dimension CHECK (dimension IN ('EDUCATION','INTERNSHIP','ABILITY','COMPLIANCE','STABILITY','GROWTH','INTEGRITY','ACTIVITY')), + CONSTRAINT ck_csa_status CHECK (status IN ('PENDING','APPROVED','REJECTED','CANCELLED','ROLLED_BACK')), + CONSTRAINT fk_csa_candidate FOREIGN KEY (candidate_user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_csa_candidate_status (candidate_user_id, status), + INDEX idx_csa_status_created (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 申诉状态流转历史 +CREATE TABLE candidate_scoring_appeal_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + appeal_id BIGINT NOT NULL, + from_status VARCHAR(20), + to_status VARCHAR(20) NOT NULL, + actor_user_id BIGINT NOT NULL, + actor_role VARCHAR(20) NOT NULL, + reason VARCHAR(500), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_csah_appeal FOREIGN KEY (appeal_id) REFERENCES candidate_scoring_appeals(id) ON DELETE CASCADE, + INDEX idx_csah_appeal (appeal_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 快照变更审计(回滚前后完整变化) +CREATE TABLE candidate_scoring_snapshot_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + appeal_id BIGINT NOT NULL, + candidate_user_id BIGINT NOT NULL, + dimension VARCHAR(20) NOT NULL, + period VARCHAR(7) NOT NULL, + action VARCHAR(20) NOT NULL, + before_score DECIMAL(5,2), + after_score DECIMAL(5,2), + before_version BIGINT, + after_version BIGINT, + before_rules_version VARCHAR(40), + after_rules_version VARCHAR(40), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_cssh_appeal FOREIGN KEY (appeal_id) REFERENCES candidate_scoring_appeals(id) ON DELETE CASCADE, + CONSTRAINT fk_cssh_candidate FOREIGN KEY (candidate_user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_cssh_key (candidate_user_id, dimension, period, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 每学生每自然年回滚次数限制(并发安全:事务内 SELECT ... FOR UPDATE) +CREATE TABLE candidate_scoring_rollback_limits ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + student_user_id BIGINT NOT NULL, + rollback_year INT NOT NULL, + rollback_count INT NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uk_csrl_student_year UNIQUE (student_user_id, rollback_year), + CONSTRAINT fk_csrl_student FOREIGN KEY (student_user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mydreamjob-server/src/main/resources/db/migration/V26__offer_advance.sql b/mydreamjob-server/src/main/resources/db/migration/V26__offer_advance.sql new file mode 100644 index 0000000..3ca878c --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V26__offer_advance.sql @@ -0,0 +1,41 @@ +-- Flyway V26: Offer 进阶 +-- 新增:入职日期、薪酬明细、Offer 撤销(WITHDRAWN)、候选人还价(offer_counter_offers) + +-- 1. offers 表新增字段(向后兼容,均为可选,H2 兼容:每条 ALTER 独立) +ALTER TABLE offers ADD COLUMN onboarding_date DATE NULL; +ALTER TABLE offers ADD COLUMN base_salary INT NULL; +ALTER TABLE offers ADD COLUMN housing_allowance INT NULL; +ALTER TABLE offers ADD COLUMN annual_bonus_months INT NULL; +ALTER TABLE offers ADD COLUMN other_allowances VARCHAR(1000) NULL; + +-- 2. 新增字段非负数 CHECK 约束 +ALTER TABLE offers ADD CONSTRAINT ck_offers_base_salary CHECK (base_salary IS NULL OR base_salary >= 0); +ALTER TABLE offers ADD CONSTRAINT ck_offers_housing_allowance CHECK (housing_allowance IS NULL OR housing_allowance >= 0); +ALTER TABLE offers ADD CONSTRAINT ck_offers_annual_bonus CHECK (annual_bonus_months IS NULL OR annual_bonus_months >= 0); + +-- 3. 候选人还价表 +-- active_key:可空判别列,配合唯一索引实现"同一 Offer 同时只能一个 PENDING 还价"。 +-- PENDING 时 = 'ACTIVE',ACCEPTED/REJECTED/EXPIRED 时 = NULL +-- MySQL 不支持 partial unique index,用可空列 + 唯一索引等价表达。 +CREATE TABLE offer_counter_offers ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + offer_id BIGINT NOT NULL, + student_id BIGINT NOT NULL, + proposed_salary_min INT, + proposed_salary_max INT, + proposed_onboarding_date DATE, + reason VARCHAR(2000), + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + active_key VARCHAR(64) NULL, + version BIGINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_co_offer FOREIGN KEY (offer_id) REFERENCES offers(id), + CONSTRAINT fk_co_student FOREIGN KEY (student_id) REFERENCES users(id), + CONSTRAINT uk_co_active UNIQUE (offer_id, active_key), + CONSTRAINT ck_co_salary_min CHECK (proposed_salary_min IS NULL OR proposed_salary_min >= 0), + CONSTRAINT ck_co_salary_max CHECK (proposed_salary_max IS NULL OR proposed_salary_max >= 0), + CONSTRAINT ck_co_salary_range CHECK (proposed_salary_min IS NULL OR proposed_salary_max IS NULL OR proposed_salary_min <= proposed_salary_max), + INDEX idx_co_offer_status (offer_id, status), + INDEX idx_co_offer_created (offer_id, created_at) +); \ No newline at end of file diff --git a/mydreamjob-server/src/main/resources/db/migration/V27__student_privacy_settings.sql b/mydreamjob-server/src/main/resources/db/migration/V27__student_privacy_settings.sql new file mode 100644 index 0000000..15ad2b4 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V27__student_privacy_settings.sql @@ -0,0 +1,17 @@ +-- Flyway V27: 学生隐私设置 +-- 每个学生至多一行,resume_visibility 限制为三个枚举值之一。 +-- 兼容 MySQL 8 与 H2 MODE=MySQL。 +CREATE TABLE student_privacy_settings ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + student_user_id BIGINT NOT NULL, + resume_visibility VARCHAR(32) NOT NULL DEFAULT 'PRIVATE', + contact_visible BOOLEAN NOT NULL DEFAULT FALSE, + version BIGINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uq_privacy_student UNIQUE (student_user_id), + CONSTRAINT chk_privacy_visibility CHECK ( + resume_visibility IN ('PRIVATE', 'APPLIED_COMPANIES', 'VERIFIED_COMPANIES') + ), + CONSTRAINT fk_privacy_student FOREIGN KEY (student_user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; \ No newline at end of file diff --git a/mydreamjob-server/src/main/resources/db/migration/V28__account_deletions.sql b/mydreamjob-server/src/main/resources/db/migration/V28__account_deletions.sql new file mode 100644 index 0000000..9f1a3f1 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V28__account_deletions.sql @@ -0,0 +1,18 @@ +-- Flyway V28: 账号注销记录 +-- 每个用户至多一行(user_id 唯一),status 限制为三个枚举值之一。 +-- 兼容 MySQL 8 与 H2 MODE=MySQL。 +CREATE TABLE account_deletions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'NONE', + requested_at DATETIME NULL, + effective_at DATETIME NULL, + cancelled_at DATETIME NULL, + completed_at DATETIME NULL, + version BIGINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT uq_acct_deletion_user UNIQUE (user_id), + CONSTRAINT fk_acct_deletion_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT chk_acct_deletion_status CHECK (status IN ('NONE', 'PENDING', 'COMPLETED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; \ No newline at end of file diff --git a/mydreamjob-server/src/main/resources/db/migration/V29__account_deletion_phone_tombstone.sql b/mydreamjob-server/src/main/resources/db/migration/V29__account_deletion_phone_tombstone.sql new file mode 100644 index 0000000..257ec1a --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V29__account_deletion_phone_tombstone.sql @@ -0,0 +1,8 @@ +-- Flyway V29: 账号注销手机号墓碑 +-- 在 account_deletions 新增 deleted_phone_hash,用于禁止原手机号重新注册。 +-- 只保存原手机号的 HMAC-SHA256 摘要(服务端密钥 app.deletion.phone-hash-secret), +-- 不保存明文手机号、不写入 audit_logs。已注销账号仍以 deleted_ 作为 users.phone。 +-- 兼容 MySQL 8 与 H2 MODE=MySQL。 +ALTER TABLE account_deletions ADD COLUMN deleted_phone_hash VARCHAR(128) NULL; + +ALTER TABLE account_deletions ADD CONSTRAINT uq_acct_deletion_phone_hash UNIQUE (deleted_phone_hash); \ No newline at end of file diff --git a/mydreamjob-server/src/main/resources/db/migration/V30__personal_data_export_tasks.sql b/mydreamjob-server/src/main/resources/db/migration/V30__personal_data_export_tasks.sql new file mode 100644 index 0000000..973e41e --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V30__personal_data_export_tasks.sql @@ -0,0 +1,30 @@ +-- Flyway V30: 个人数据导出任务 +-- 兼容 MySQL 8.0 与 H2 MODE=MySQL(内嵌测试库) +CREATE TABLE personal_data_export_tasks ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL, + format VARCHAR(10) NOT NULL, + idempotency_key VARCHAR(64), + requested_at DATETIME NOT NULL, + started_at DATETIME NULL, + completed_at DATETIME NULL, + expires_at DATETIME NULL, + downloaded_at DATETIME NULL, + storage_key VARCHAR(500) NULL, + size_bytes BIGINT NULL, + checksum_sha256 VARCHAR(64) NULL, + error_code VARCHAR(50) NULL, + error_message VARCHAR(500) NULL, + version BIGINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_pdet_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT chk_pdet_status CHECK (status IN ('PENDING','PROCESSING','COMPLETED','FAILED','EXPIRED','CANCELLED')), + CONSTRAINT chk_pdet_format CHECK (format IN ('ZIP')) +); + +CREATE INDEX idx_pdet_user_status ON personal_data_export_tasks (user_id, status); +CREATE INDEX idx_pdet_user_created ON personal_data_export_tasks (user_id, created_at); +-- 用户维度幂等:同 (user_id, idempotency_key) 唯一;NULL key 允许重复(MySQL/H2 唯一索引允许多个 NULL) +CREATE UNIQUE INDEX uniq_pdet_user_key ON personal_data_export_tasks (user_id, idempotency_key); \ No newline at end of file diff --git a/mydreamjob-server/src/main/resources/db/migration/V31__interview_scores.sql b/mydreamjob-server/src/main/resources/db/migration/V31__interview_scores.sql new file mode 100644 index 0000000..17e5414 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V31__interview_scores.sql @@ -0,0 +1,161 @@ +-- Flyway V31: 结构化面试评分模块 +-- 一场面试一份评分(interview_id 唯一);评分人 = 岗位所属企业 owner。 +-- 快照历史使用 TEXT(Jackson 生成 JSON 字符串),不用 JSON 类型,规避 MySQL/H2 差异。 + +CREATE TABLE interview_scores ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + interview_id BIGINT NOT NULL, + application_id BIGINT NOT NULL, + company_user_id BIGINT NOT NULL, + student_user_id BIGINT NOT NULL, + + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + + technical_score INT NULL, + communication_score INT NULL, + problem_solving_score INT NULL, + role_fit_score INT NULL, + professionalism_score INT NULL, + + overall_score DECIMAL(3,2) NULL, + recommendation VARCHAR(20) NULL, + comment TEXT NULL, + submitted_at DATETIME NULL, + + version BIGINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT uk_interview_scores_interview + UNIQUE (interview_id), + + CONSTRAINT fk_interview_scores_interview + FOREIGN KEY (interview_id) + REFERENCES interviews(id) + ON DELETE CASCADE, + + CONSTRAINT fk_interview_scores_application + FOREIGN KEY (application_id) + REFERENCES applications(id) + ON DELETE CASCADE, + + CONSTRAINT fk_interview_scores_company_user + FOREIGN KEY (company_user_id) + REFERENCES users(id) + ON DELETE CASCADE, + + CONSTRAINT fk_interview_scores_student_user + FOREIGN KEY (student_user_id) + REFERENCES users(id) + ON DELETE CASCADE, + + CONSTRAINT chk_interview_scores_status + CHECK (status IN ('DRAFT', 'SUBMITTED')), + + CONSTRAINT chk_interview_scores_recommendation + CHECK ( + recommendation IS NULL + OR recommendation IN ( + 'STRONG_HIRE', + 'HIRE', + 'NEUTRAL', + 'NO_HIRE' + ) + ), + + CONSTRAINT chk_interview_scores_technical + CHECK ( + technical_score IS NULL + OR technical_score BETWEEN 1 AND 5 + ), + + CONSTRAINT chk_interview_scores_communication + CHECK ( + communication_score IS NULL + OR communication_score BETWEEN 1 AND 5 + ), + + CONSTRAINT chk_interview_scores_problem_solving + CHECK ( + problem_solving_score IS NULL + OR problem_solving_score BETWEEN 1 AND 5 + ), + + CONSTRAINT chk_interview_scores_role_fit + CHECK ( + role_fit_score IS NULL + OR role_fit_score BETWEEN 1 AND 5 + ), + + CONSTRAINT chk_interview_scores_professionalism + CHECK ( + professionalism_score IS NULL + OR professionalism_score BETWEEN 1 AND 5 + ), + + CONSTRAINT chk_interview_scores_overall + CHECK ( + overall_score IS NULL + OR overall_score BETWEEN 1.00 AND 5.00 + ), + + CONSTRAINT chk_interview_scores_submitted + CHECK ( + status = 'DRAFT' + OR ( + technical_score IS NOT NULL + AND communication_score IS NOT NULL + AND problem_solving_score IS NOT NULL + AND role_fit_score IS NOT NULL + AND professionalism_score IS NOT NULL + AND overall_score IS NOT NULL + AND submitted_at IS NOT NULL + ) + ), + + INDEX idx_interview_scores_company_user (company_user_id), + INDEX idx_interview_scores_student_user (student_user_id), + INDEX idx_interview_scores_application (application_id) +) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE interview_score_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + score_id BIGINT NOT NULL, + interview_id BIGINT NOT NULL, + + from_status VARCHAR(20) NULL, + to_status VARCHAR(20) NOT NULL, + + actor_user_id BIGINT NOT NULL, + actor_role VARCHAR(20) NOT NULL, + + snapshot_text TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_interview_score_history_score + FOREIGN KEY (score_id) + REFERENCES interview_scores(id) + ON DELETE CASCADE, + + CONSTRAINT fk_interview_score_history_interview + FOREIGN KEY (interview_id) + REFERENCES interviews(id) + ON DELETE CASCADE, + + CONSTRAINT fk_interview_score_history_actor + FOREIGN KEY (actor_user_id) + REFERENCES users(id) + ON DELETE CASCADE, + + CONSTRAINT chk_interview_score_history_to_status + CHECK (to_status IN ('DRAFT', 'SUBMITTED')), + + INDEX idx_interview_score_history_score (score_id), + INDEX idx_interview_score_history_interview (interview_id), + INDEX idx_interview_score_history_created (created_at) +) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci; \ No newline at end of file diff --git a/mydreamjob-server/src/main/resources/db/migration/V33__report_block.sql b/mydreamjob-server/src/main/resources/db/migration/V33__report_block.sql new file mode 100644 index 0000000..a1e4b38 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V33__report_block.sql @@ -0,0 +1,128 @@ +-- Flyway V33: 举报与屏蔽模块 +-- 三张表: +-- 1. reports 举报单(USER / MESSAGE 目标,服务端推导 reported_user_id) +-- 2. block_relations 屏蔽关系(blocker -> blocked,双向唯一防重复) +-- 3. report_decision_history ADMIN 处置流转留痕 +-- evidence_urls 使用 TEXT 保存 JSON 字符串,不引入 MySQL JSON 列,规避既有 JSON 序列化兼容问题。 + +-- 举报单 +CREATE TABLE reports ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + reporter_user_id BIGINT NOT NULL, + target_type VARCHAR(20) NOT NULL, + target_user_id BIGINT NULL, + message_id BIGINT NULL, + reported_user_id BIGINT NOT NULL, + category VARCHAR(40) NOT NULL, + reason VARCHAR(1000) NOT NULL, + evidence_urls TEXT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + idempotency_key VARCHAR(64) NULL, + active_key VARCHAR(255) NULL, + version BIGINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT uk_reports_idempotency + UNIQUE (reporter_user_id, idempotency_key), + CONSTRAINT uk_reports_active + UNIQUE (active_key), + + CONSTRAINT fk_reports_reporter + FOREIGN KEY (reporter_user_id) + REFERENCES users(id) + ON DELETE CASCADE, + CONSTRAINT fk_reports_reported + FOREIGN KEY (reported_user_id) + REFERENCES users(id) + ON DELETE CASCADE, + CONSTRAINT fk_reports_message + FOREIGN KEY (message_id) + REFERENCES messages(id) + ON DELETE SET NULL, + + -- 注意:message_id 具外键 referential action(ON DELETE SET NULL),MySQL 3823 禁止其出现在 CHECK 中, + -- 故跨列一致性(USER 传 targetUserId / MESSAGE 传 messageId)由应用层 ReportService 强校验(拒绝注入 reportedUserId)。 + CONSTRAINT chk_reports_target_type + CHECK ( + (target_type = 'USER' AND target_user_id IS NOT NULL) + OR target_type = 'MESSAGE' + ), + CONSTRAINT chk_reports_status + CHECK (status IN ('PENDING', 'RESOLVED', 'REJECTED')), + CONSTRAINT chk_reports_category + CHECK (category IN ( + 'HARASSMENT', + 'FRAUD', + 'SPAM', + 'IMPERSONATION', + 'INAPPROPRIATE_CONTENT', + 'ILLEGAL', + 'OTHER' + )), + + INDEX idx_reports_reporter_created (reporter_user_id, created_at), + INDEX idx_reports_status_created (status, created_at), + INDEX idx_reports_reported_created (reported_user_id, created_at), + INDEX idx_reports_message (message_id) +) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci; + +-- 屏蔽关系 +CREATE TABLE block_relations ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + blocker_user_id BIGINT NOT NULL, + blocked_user_id BIGINT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT uk_block_relations_pair + UNIQUE (blocker_user_id, blocked_user_id), + + CONSTRAINT fk_block_relations_blocker + FOREIGN KEY (blocker_user_id) + REFERENCES users(id) + ON DELETE CASCADE, + CONSTRAINT fk_block_relations_blocked + FOREIGN KEY (blocked_user_id) + REFERENCES users(id) + ON DELETE CASCADE, + -- 禁止自己屏蔽自己:blocker/blocked 均具 CASCADE 外键 action,MySQL 3823 禁止写入 CHECK, + -- 改由应用层 BlockService 返回 CANNOT_BLOCK_SELF(9608) + + INDEX idx_block_relations_blocker (blocker_user_id), + INDEX idx_block_relations_blocked (blocked_user_id) +) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci; + +-- 举报处置流转历史 +CREATE TABLE report_decision_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + report_id BIGINT NOT NULL, + from_status VARCHAR(20) NULL, + to_status VARCHAR(20) NOT NULL, + actor_user_id BIGINT NOT NULL, + actor_role VARCHAR(30) NOT NULL, + note TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_report_history_report + FOREIGN KEY (report_id) + REFERENCES reports(id) + ON DELETE CASCADE, + CONSTRAINT fk_report_history_actor + FOREIGN KEY (actor_user_id) + REFERENCES users(id) + ON DELETE CASCADE, + + CONSTRAINT chk_report_history_to_status + CHECK (to_status IN ('RESOLVED', 'REJECTED')), + + INDEX idx_report_history_report_created (report_id, created_at) +) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci; \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java new file mode 100644 index 0000000..1469230 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java @@ -0,0 +1,319 @@ +package com.mydreamjob.application; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.application.dto.CandidateDetailView; +import com.mydreamjob.application.dto.CandidateItemView; +import com.mydreamjob.application.dto.CandidateListResponse; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.User.Role; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class ApplicationControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + @MockBean private ApplicationService applicationService; + @MockBean private CompanyService companyService; + @MockBean private UserRepository userRepository; + @MockBean private RedisTemplate redisTemplate; + private final ObjectMapper mapper = new ObjectMapper(); + + private static final Long COMPANY_USER_ID = 10L; + private static final Long STUDENT_USER_ID = 20L; + private static final Long ADMIN_USER_ID = 30L; + private static final Long COMPANY_ID = 1L; + private static final Long JOB_ID = 50L; + private static final Long APP_ID = 100L; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + + // Default: COMPANY user exists + User companyUser = new User(); + companyUser.setRoles(Set.of(Role.COMPANY)); + when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(companyUser)); + + // STUDENT user + User studentUser = new User(); + studentUser.setRoles(Set.of(Role.STUDENT)); + when(userRepository.findById(STUDENT_USER_ID)).thenReturn(Optional.of(studentUser)); + + // ADMIN user + User adminUser = new User(); + adminUser.setRoles(Set.of(Role.ADMIN)); + when(userRepository.findById(ADMIN_USER_ID)).thenReturn(Optional.of(adminUser)); + } + + private String companyToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken( + COMPANY_USER_ID, "1", Map.of("roles", "COMPANY")); + } + + private String studentToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken( + STUDENT_USER_ID, "1", Map.of("roles", "STUDENT")); + } + + private String adminToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken( + ADMIN_USER_ID, "1", Map.of("roles", "ADMIN")); + } + + private void mockCompanyExists() { + Company company = Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).name("测试企业").build(); + when(companyService.findCompanyByOwnerUserId(COMPANY_USER_ID)).thenReturn(company); + } + + // ── 正常访问 ──────────────────────────────────────────────── + + @Test void list_companyNormal_shouldReturn200() throws Exception { + mockCompanyExists(); + CandidateListResponse resp = CandidateListResponse.builder() + .items(List.of()).total(0).page(0).size(20).totalPages(0).build(); + when(applicationService.listCompanyCandidates(anyLong(), isNull(), isNull(), isNull(), isNull(), isNull(), anyInt(), anyInt())) + .thenReturn(resp); + + mockMvc.perform(get("/company/candidates") + .header("Authorization", companyToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(0)); + } + + @Test void detail_companyNormal_shouldReturn200() throws Exception { + mockCompanyExists(); + CandidateDetailView detail = CandidateDetailView.builder() + .applicationId(APP_ID).candidateName("张三").build(); + when(applicationService.getCompanyCandidateDetail(COMPANY_ID, APP_ID)).thenReturn(detail); + + mockMvc.perform(get("/company/candidates/" + APP_ID) + .header("Authorization", companyToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.candidateName").value("张三")); + } + + // ── 权限 ──────────────────────────────────────────────────── + + @Test void list_noToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/company/candidates")) + .andExpect(status().isUnauthorized()); + } + + @Test void list_student_shouldReturn403() throws Exception { + mockMvc.perform(get("/company/candidates") + .header("Authorization", studentToken())) + .andExpect(status().isForbidden()); + } + + @Test void list_admin_shouldReturn403() throws Exception { + mockMvc.perform(get("/company/candidates") + .header("Authorization", adminToken())) + .andExpect(status().isForbidden()); + } + + @Test void detail_student_shouldReturn403() throws Exception { + mockMvc.perform(get("/company/candidates/" + APP_ID) + .header("Authorization", studentToken())) + .andExpect(status().isForbidden()); + } + + @Test void detail_noToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/company/candidates/" + APP_ID)) + .andExpect(status().isUnauthorized()); + } + + @Test void list_companyNotFound_shouldReturn404() throws Exception { + when(companyService.findCompanyByOwnerUserId(COMPANY_USER_ID)).thenReturn(null); + + mockMvc.perform(get("/company/candidates") + .header("Authorization", companyToken())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(5001)); + } + + // ── 越权 ──────────────────────────────────────────────────── + + @Test void detail_wrongCompany_shouldReturn403() throws Exception { + mockCompanyExists(); + when(applicationService.getCompanyCandidateDetail(COMPANY_ID, APP_ID)) + .thenThrow(new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH)); + + mockMvc.perform(get("/company/candidates/" + APP_ID) + .header("Authorization", companyToken())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(8004)); + } + + // ── 参数校验-分页 ──────────────────────────────────────────── + + @Test void list_pageNegative_shouldReturn400() throws Exception { + mockCompanyExists(); + mockMvc.perform(get("/company/candidates?page=-1") + .header("Authorization", companyToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1001)); + } + + @Test void list_sizeZero_shouldReturn400() throws Exception { + mockCompanyExists(); + mockMvc.perform(get("/company/candidates?size=0") + .header("Authorization", companyToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1001)); + } + + @Test void list_sizeTooLarge_shouldReturn400() throws Exception { + mockCompanyExists(); + mockMvc.perform(get("/company/candidates?size=101") + .header("Authorization", companyToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1001)); + } + + // ── 参数校验-status ────────────────────────────────────────── + + @Test void list_invalidStatus_shouldReturn400() throws Exception { + mockCompanyExists(); + mockMvc.perform(get("/company/candidates?status=INVALID") + .header("Authorization", companyToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1001)); + } + + // ── 参数校验-sort/order ────────────────────────────────────── + + @Test void list_invalidSort_shouldReturn400() throws Exception { + mockCompanyExists(); + mockMvc.perform(get("/company/candidates?sort=invalid") + .header("Authorization", companyToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1001)); + } + + @Test void list_invalidOrder_shouldReturn400() throws Exception { + mockCompanyExists(); + mockMvc.perform(get("/company/candidates?order=invalid") + .header("Authorization", companyToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1001)); + } + + // ── 参数校验-keyword ───────────────────────────────────────── + + @Test void list_keywordTooLong_shouldReturn400() throws Exception { + mockCompanyExists(); + String longKeyword = "a".repeat(101); + mockMvc.perform(get("/company/candidates?keyword=" + longKeyword) + .header("Authorization", companyToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1001)); + } + + // ── 参数校验-jobId ─────────────────────────────────────────── + + @Test void list_jobIdZero_shouldReturn400() throws Exception { + mockCompanyExists(); + mockMvc.perform(get("/company/candidates?jobId=0") + .header("Authorization", companyToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1001)); + } + + @Test void list_jobIdNegative_shouldReturn400() throws Exception { + mockCompanyExists(); + mockMvc.perform(get("/company/candidates?jobId=-1") + .header("Authorization", companyToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1001)); + } + + // ── 详情-不存在 ───────────────────────────────────────────── + + @Test void detail_appNotFound_shouldReturn404() throws Exception { + mockCompanyExists(); + when(applicationService.getCompanyCandidateDetail(COMPANY_ID, APP_ID)) + .thenThrow(new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); + + mockMvc.perform(get("/company/candidates/" + APP_ID) + .header("Authorization", companyToken())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(8002)); + } + + // ── 敏感字段隔离 ──────────────────────────────────────────── + + @Test void list_shouldNotExposeSensitiveFields() throws Exception { + mockCompanyExists(); + CandidateItemView item = CandidateItemView.builder() + .applicationId(APP_ID).jobId(JOB_ID).jobTitle("Java").status("SUBMITTED") + .candidateName("张三").candidateSchool("清华").candidateEducation("本科") + .candidateGraduationYear((short)2026).candidateAvatarUrl("https://img/1.png") + .createdAt(LocalDateTime.now()).updatedAt(LocalDateTime.now()).build(); + CandidateListResponse resp = CandidateListResponse.builder() + .items(List.of(item)).total(1).page(0).size(20).totalPages(1).build(); + when(applicationService.listCompanyCandidates(anyLong(), isNull(), isNull(), isNull(), isNull(), isNull(), anyInt(), anyInt())) + .thenReturn(resp); + + mockMvc.perform(get("/company/candidates") + .header("Authorization", companyToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].candidateName").value("张三")) + .andExpect(jsonPath("$.data.items[0].studentUserId").doesNotExist()) + .andExpect(jsonPath("$.data.items[0].phone").doesNotExist()); + } + + @Test void detail_shouldNotExposeSensitiveFields_butIncludeCoverLetter() throws Exception { + mockCompanyExists(); + CandidateDetailView detail = CandidateDetailView.builder() + .applicationId(APP_ID).jobId(JOB_ID).jobTitle("Java") + .jobCity("北京").jobEmploymentType("全职").status("SUBMITTED") + .coverLetter("这是我的自我介绍") + .candidateName("张三").candidateAvatarUrl("https://img/1.png") + .candidateBio("bio").candidateLocation("北京").candidateSchool("清华") + .candidateEducation("本科").candidateGraduationYear((short)2026) + .candidateSkills(List.of("Java", "Spring")) + .createdAt(LocalDateTime.now()).updatedAt(LocalDateTime.now()) + .build(); + when(applicationService.getCompanyCandidateDetail(COMPANY_ID, APP_ID)).thenReturn(detail); + + mockMvc.perform(get("/company/candidates/" + APP_ID) + .header("Authorization", companyToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.coverLetter").value("这是我的自我介绍")) + .andExpect(jsonPath("$.data.phone").doesNotExist()) + .andExpect(jsonPath("$.data.passwordHash").doesNotExist()) + .andExpect(jsonPath("$.data.resumeUrl").doesNotExist()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java index 89d5766..092393d 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java @@ -5,17 +5,25 @@ import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.application.dto.ApplicationCreateRequest; import com.mydreamjob.application.dto.ApplicationStatusRequest; +import com.mydreamjob.application.dto.CandidateDetailView; +import com.mydreamjob.application.dto.CandidateListResponse; +import com.mydreamjob.profile.StudentProfile; +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @@ -28,6 +36,7 @@ class ApplicationServiceTest { @Mock private AuditService auditService; @Mock private ApplicationStatusHistoryRepository historyRepository; @Mock private com.mydreamjob.notification.NotificationService notificationService; + @Mock private EntityManager em; @InjectMocks private ApplicationService applicationService; private static final Long STUDENT_ID = 100L; @@ -39,6 +48,7 @@ class ApplicationServiceTest { @BeforeEach void setUp() { + ReflectionTestUtils.setField(applicationService, "em", em); publishedJob = com.mydreamjob.job.Job.builder().id(JOB_ID).companyId(COMPANY_ID).title("Java").status("PUBLISHED").build(); application = Application.builder().id(APP_ID).jobId(JOB_ID).studentUserId(STUDENT_ID).status("SUBMITTED").build(); } @@ -152,4 +162,240 @@ class ApplicationServiceTest { Application result = applicationService.apply(STUDENT_ID, req); assertEquals("SUBMITTED", result.getStatus()); } + + // ── 企业候选人列表 ───────────────────────────────────────── + + @SuppressWarnings("unchecked") + private TypedQuery mockCountQuery(long total) { + TypedQuery q = mock(TypedQuery.class); + when(q.getSingleResult()).thenReturn(total); + return q; + } + + @SuppressWarnings("unchecked") + private TypedQuery mockDataQuery(Object[]... rows) { + TypedQuery q = mock(TypedQuery.class); + when(q.getResultList()).thenReturn(List.of(rows)); + return q; + } + + private Object[] buildRow(Long appId, Long jobId, String jobTitle, String status, + String name, String school, String edu, Short gradYear, + String avatarUrl) { + return new Object[]{appId, jobId, jobTitle, status, name, school, edu, gradYear, avatarUrl, + java.time.LocalDateTime.now(), java.time.LocalDateTime.now()}; + } + + @Test void listCompanyCandidates_shouldReturnPaginatedResults() { + Object[] row = buildRow(200L, JOB_ID, "Java工程师", "SUBMITTED", + "张三", "清华大学", "本科", (short)2026, "https://img/1.png"); + TypedQuery countQ = mockCountQuery(1L); + TypedQuery dataQ = mockDataQuery(row); + when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQ); + when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(dataQ); + + CandidateListResponse resp = applicationService.listCompanyCandidates( + COMPANY_ID, null, null, null, null, null, 0, 20); + assertEquals(1, resp.getItems().size()); + assertEquals("张三", resp.getItems().get(0).getCandidateName()); + assertEquals(1, resp.getTotal()); + } + + @Test void listCompanyCandidates_shouldFilterByJobId() { + Object[] row = buildRow(200L, JOB_ID, "Java", "SUBMITTED", + "张三", "清华", "本科", null, null); + TypedQuery countQ = mockCountQuery(1L); + TypedQuery dataQ = mockDataQuery(row); + when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQ); + when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(dataQ); + + CandidateListResponse resp = applicationService.listCompanyCandidates( + COMPANY_ID, JOB_ID, null, null, null, null, 0, 20); + assertEquals(1, resp.getItems().size()); + assertEquals(JOB_ID, resp.getItems().get(0).getJobId()); + } + + @Test void listCompanyCandidates_shouldFilterByStatus() { + Object[] row = buildRow(200L, JOB_ID, "Java", "SUBMITTED", + "张三", "清华", "本科", null, null); + TypedQuery countQ = mockCountQuery(1L); + TypedQuery dataQ = mockDataQuery(row); + when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQ); + when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(dataQ); + + CandidateListResponse resp = applicationService.listCompanyCandidates( + COMPANY_ID, null, "SUBMITTED", null, null, null, 0, 20); + assertEquals("SUBMITTED", resp.getItems().get(0).getStatus()); + } + + @Test void listCompanyCandidates_shouldReturnEmptyForNoResults() { + TypedQuery countQ = mockCountQuery(0L); + TypedQuery dataQ = mockDataQuery(); + when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQ); + when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(dataQ); + + CandidateListResponse resp = applicationService.listCompanyCandidates( + COMPANY_ID, null, null, null, null, null, 0, 20); + assertEquals(0, resp.getItems().size()); + assertEquals(0, resp.getTotal()); + } + + @Test void listCompanyCandidates_invalidPage_shouldThrow() { + var ex = assertThrows(BusinessException.class, () -> + applicationService.listCompanyCandidates(COMPANY_ID, null, null, null, null, null, -1, 20)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test void listCompanyCandidates_invalidSize_shouldThrow() { + var ex = assertThrows(BusinessException.class, () -> + applicationService.listCompanyCandidates(COMPANY_ID, null, null, null, null, null, 0, 0)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test void listCompanyCandidates_sizeTooLarge_shouldThrow() { + var ex = assertThrows(BusinessException.class, () -> + applicationService.listCompanyCandidates(COMPANY_ID, null, null, null, null, null, 0, 101)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test void listCompanyCandidates_invalidStatus_shouldThrow() { + var ex = assertThrows(BusinessException.class, () -> + applicationService.listCompanyCandidates(COMPANY_ID, null, "INVALID", null, null, null, 0, 20)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test void listCompanyCandidates_invalidSort_shouldThrow() { + var ex = assertThrows(BusinessException.class, () -> + applicationService.listCompanyCandidates(COMPANY_ID, null, null, null, "invalid", null, 0, 20)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test void listCompanyCandidates_invalidOrder_shouldThrow() { + var ex = assertThrows(BusinessException.class, () -> + applicationService.listCompanyCandidates(COMPANY_ID, null, null, null, null, "invalid", 0, 20)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test void listCompanyCandidates_shouldSortByUpdatedAt() { + Object[] row = buildRow(200L, JOB_ID, "Java", "SUBMITTED", + "张三", "清华", "本科", null, null); + TypedQuery countQ = mockCountQuery(1L); + TypedQuery dataQ = mockDataQuery(row); + when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQ); + when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(dataQ); + + CandidateListResponse resp = applicationService.listCompanyCandidates( + COMPANY_ID, null, null, null, "updatedAt", "asc", 0, 20); + assertEquals(1, resp.getItems().size()); + } + + // ── 企业候选人详情 ───────────────────────────────────────── + + @Test void getCompanyCandidateDetail_shouldReturnDetail() { + Application app = Application.builder().id(APP_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").coverLetter("cover").build(); + com.mydreamjob.job.Job job = com.mydreamjob.job.Job.builder() + .id(JOB_ID).companyId(COMPANY_ID).title("Java").city("北京") + .employmentType("全职").build(); + StudentProfile sp = StudentProfile.builder().userId(STUDENT_ID) + .realName("张三").school("清华").education("本科").bio("bio") + .location("北京").graduationYear((short)2026).skillsJson("[\"Java\",\"Spring\"]").build(); + com.mydreamjob.auth.User u = mock(com.mydreamjob.auth.User.class); + when(u.getAvatarUrl()).thenReturn("https://img/1.png"); + + Object[] row = new Object[]{app, job, sp, u}; + @SuppressWarnings("unchecked") + TypedQuery detailQuery = mock(TypedQuery.class); + when(detailQuery.setParameter(anyString(), any())).thenReturn(detailQuery); + when(detailQuery.getResultList()).thenReturn(Collections.singletonList(row)); + when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(detailQuery); + + CandidateDetailView detail = applicationService.getCompanyCandidateDetail(COMPANY_ID, APP_ID); + assertEquals("张三", detail.getCandidateName()); + assertEquals("cover", detail.getCoverLetter()); + assertEquals("北京", detail.getJobCity()); + assertEquals(2, detail.getCandidateSkills().size()); + assertEquals("https://img/1.png", detail.getCandidateAvatarUrl()); + // 成功详情查询不产生额外 Repository 查询(无 N+1) + verify(applicationRepository, never()).findById(anyLong()); + } + + @Test void getCompanyCandidateDetail_wrongCompany_shouldThrow() { + // 投递存在,但岗位 companyId 与当前企业不匹配 → APPLICATION_OWNER_MISMATCH + Application app = Application.builder().id(APP_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").build(); + com.mydreamjob.job.Job job = com.mydreamjob.job.Job.builder() + .id(JOB_ID).companyId(COMPANY_ID).title("Java").build(); + Object[] row = new Object[]{app, job, null, null}; + @SuppressWarnings("unchecked") + TypedQuery detailQuery = mock(TypedQuery.class); + when(detailQuery.setParameter(anyString(), any())).thenReturn(detailQuery); + when(detailQuery.getResultList()).thenReturn(Collections.singletonList(row)); + when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(detailQuery); + + var ex = assertThrows(BusinessException.class, () -> + applicationService.getCompanyCandidateDetail(999L, APP_ID)); + assertEquals(ErrorCode.APPLICATION_OWNER_MISMATCH.getCode(), ex.getErrorCodeInt()); + // 越权分支不触发额外的 Repository 查询 + verify(applicationRepository, never()).findById(anyLong()); + } + + @Test void getCompanyCandidateDetail_appNotFound_shouldThrow() { + @SuppressWarnings("unchecked") + TypedQuery detailQuery = mock(TypedQuery.class); + when(detailQuery.setParameter(anyString(), any())).thenReturn(detailQuery); + when(detailQuery.getResultList()).thenReturn(Collections.emptyList()); + when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(detailQuery); + + var ex = assertThrows(BusinessException.class, () -> + applicationService.getCompanyCandidateDetail(COMPANY_ID, APP_ID)); + assertEquals(ErrorCode.APPLICATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + // 不存在分支不触发额外的 Repository 查询 + verify(applicationRepository, never()).findById(anyLong()); + } + + @Test void getCompanyCandidateDetail_noStudentProfile_shouldReturnNullFields() { + Application app = Application.builder().id(APP_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").coverLetter(null).build(); + com.mydreamjob.job.Job job = com.mydreamjob.job.Job.builder() + .id(JOB_ID).companyId(COMPANY_ID).title("Java").build(); + + Object[] row = new Object[]{app, job, null, null}; + @SuppressWarnings("unchecked") + TypedQuery detailQuery = mock(TypedQuery.class); + when(detailQuery.setParameter(anyString(), any())).thenReturn(detailQuery); + when(detailQuery.getResultList()).thenReturn(Collections.singletonList(row)); + when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(detailQuery); + + CandidateDetailView detail = applicationService.getCompanyCandidateDetail(COMPANY_ID, APP_ID); + assertNull(detail.getCandidateName()); + assertNull(detail.getCandidateSchool()); + assertNull(detail.getCoverLetter()); + } + + // ── getTimeline 已知行为回归 ──────────────────────────────── + + @Test void getTimeline_companyUser_knownBehavior() { + // 公司用户的 userId 与 companyId 不同,当前实现比较 job.companyId == userId + // 这会导致公司用户无法通过权限校验,返回 403 + when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); + when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); + // publishedJob.companyId = COMPANY_ID (1), userId = COMPANY_ID (1) -> 巧合匹配 + // 注意:实际场景中 companyId != userId,此处仅验证当前行为 + + var result = applicationService.getTimeline(APP_ID, COMPANY_ID); + // 因为 COMPANY_ID == publishedJob.companyId,当前代码会通过 + assertNotNull(result); + } + + // ── LIKE 转义 ────────────────────────────────────────────── + + @Test void escapeLike_shouldEscapeSpecialChars() { + assertEquals("test\\_key\\%word", applicationService.escapeLike("test_key%word")); + assertEquals("test\\\\slash", applicationService.escapeLike("test\\slash")); + } + + @Test void escapeLike_null_shouldReturnNull() { + assertNull(applicationService.escapeLike(null)); + } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java index 9355b85..afc76f2 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java @@ -3,6 +3,10 @@ package com.mydreamjob.auth; import com.mydreamjob.audit.AuditService; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.deletion.AccountDeletion; +import com.mydreamjob.deletion.AccountDeletionPhoneHasher; +import com.mydreamjob.deletion.AccountDeletionRepository; +import com.mydreamjob.deletion.DeletionStatus; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -32,6 +36,12 @@ class AuthServiceTest { @Mock private AuditService auditService; + @Mock + private AccountDeletionRepository accountDeletionRepository; + + @Mock + private AccountDeletionPhoneHasher accountDeletionPhoneHasher; + @InjectMocks private AuthService authService; @@ -100,6 +110,21 @@ class AuthServiceTest { () -> authService.mockLogin("13800000001", "000000", "login", null, null, null)); } + @Test + void mockLogin_shouldBlockReRegistrationOfDeletedPhone() { + when(smsVerificationService.verifyCode("13800000001", "123456", "login")).thenReturn(true); + when(accountDeletionPhoneHasher.hash("13800000001")).thenReturn("deadbeef"); + AccountDeletion tomb = AccountDeletion.builder() + .userId(1L).status(DeletionStatus.COMPLETED).deletedPhoneHash("deadbeef").build(); + when(accountDeletionRepository.findByDeletedPhoneHash("deadbeef")).thenReturn(Optional.of(tomb)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> authService.mockLogin("13800000001", "123456", "login", null, null, null)); + assertEquals(ErrorCode.ACCOUNT_DELETED.getCode(), ex.getErrorCodeInt()); + // 不得自动创建新用户 + verify(userRepository, never()).save(any()); + } + @Test void getCurrentUser_shouldThrowWhenUserNotFound() { when(userRepository.findById(999L)).thenReturn(Optional.empty()); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java new file mode 100644 index 0000000..0bf6ea4 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java @@ -0,0 +1,268 @@ +package com.mydreamjob.candidatescore; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +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.Query; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CandidateScoringCalculatorTest { + + @Mock private StudentProfileRepository profileRepository; + @Mock private StudentPreferenceRepository preferenceRepository; + @Mock private UserRepository userRepository; + @Mock private JdbcTemplate jdbcTemplate; + @Mock private EntityManager entityManager; + @InjectMocks private CandidateScoringCalculator calculator; + + private static final Long STUDENT_ID = 2L; + private Query queryMock; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(calculator, "entityManager", entityManager); + queryMock = mock(Query.class); + when(queryMock.setParameter(anyInt(), any())).thenReturn(queryMock); + when(entityManager.createNativeQuery(anyString())).thenReturn(queryMock); + stubAggregates(); + } + + private void stubAggregates() { + when(jdbcTemplate.queryForObject(anyString(), ArgumentMatchers.>any(), any(Object[].class))) + .thenAnswer(inv -> { + String sql = inv.getArgument(0); + if (sql.contains("FROM applications")) + return new CandidateScoringCalculator.AppAgg(3, 0, LocalDateTime.now().minusMonths(24), 3); + if (sql.contains("FROM audit_logs")) + return new CandidateScoringCalculator.AuditAgg(4, 3); + if (sql.contains("FROM offers")) + return new CandidateScoringCalculator.OfferAgg(1, 0); + if (sql.contains("FROM candidate_feedback")) + return new CandidateScoringCalculator.FbAgg(3, 4.5); + if (sql.contains("FROM resume_versions")) + return new CandidateScoringCalculator.ResumeAgg(2, LocalDateTime.now().minusDays(30)); + return null; + }); + when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(2L); + } + + private User user() { + User u = User.builder().id(STUDENT_ID).phone("1").build(); + u.setCreatedAt(LocalDateTime.now().minusMonths(24)); + return u; + } + + private StudentPreference fullPref() { + return StudentPreference.builder().studentUserId(STUDENT_ID) + .expectedSalaryMin(10000).expectedSalaryMax(20000) + .preferredCities("[\"北京\"]").employmentTypes("[\"FULL_TIME\"]") + .preferredIndustries("[\"互联网\"]").preferredJobTypes("[\"BACKEND\"]") + .remoteAllowed(true).build(); + } + + // ── EDUCATION ───────────────────────────────────────────── + + @Test void education_missing_shouldBeInsufficient() { + CandidateScoringCalculator.DimensionResult r = + calculator.computeEducation(StudentProfile.builder().userId(STUDENT_ID).build()); + assertEquals("INSUFFICIENT_DATA", r.status); + assertNull(r.score); + } + + @Test void education_985_undergraduate_shouldScore() { + CandidateScoringCalculator.DimensionResult r = calculator.computeEducation( + StudentProfile.builder().userId(STUDENT_ID).education("本科").school("清华大学").build()); + assertEquals("OK", r.status); + assertEquals(new BigDecimal("76.75"), r.score); + } + + @Test void education_211_master_shouldScore() { + CandidateScoringCalculator.DimensionResult r = calculator.computeEducation( + StudentProfile.builder().userId(STUDENT_ID).education("硕士").school("北京邮电大学").build()); + assertEquals("OK", r.status); + assertEquals(new BigDecimal("80.50"), r.score); + } + + @Test void education_unknownSchool_shouldBeEstimated() { + CandidateScoringCalculator.DimensionResult r = calculator.computeEducation( + StudentProfile.builder().userId(STUDENT_ID).education("本科").school("未知学校X").build()); + assertEquals("ESTIMATED", r.status); + assertEquals(new BigDecimal("61.00"), r.score); + } + + // ── PENDING ─────────────────────────────────────────────── + + @Test void internshipAndAbility_shouldBePending() { + assertEquals("PENDING", calculator.computeInternship().status); + assertEquals("PENDING", calculator.computeAbility().status); + } + + // ── COMPLIANCE ──────────────────────────────────────────── + + @Test void compliance_allFive_shouldBe100() { + CandidateScoringCalculator.DimensionResult r = calculator.computeCompliance(fullPref()); + assertEquals("OK", r.status); + assertEquals(new BigDecimal("100.00"), r.score); + } + + @Test void compliance_onlySalary_shouldBeEstimated25() { + StudentPreference p = new StudentPreference(); + p.setExpectedSalaryMin(10000); + CandidateScoringCalculator.DimensionResult r = calculator.computeCompliance(p); + assertEquals("ESTIMATED", r.status); + assertEquals(new BigDecimal("25.00"), r.score); + } + + // ── STABILITY / GROWTH / INTEGRITY / ACTIVITY ───────────── + + @Test void stability_noActivity_shouldBeInsufficient() { + assertEquals("INSUFFICIENT_DATA", + calculator.computeStability(LocalDateTime.now().minusMonths(1), 0, 0, 0).status); + } + + @Test void stability_longAccount_shouldScore100() { + assertEquals(new BigDecimal("100.00"), + calculator.computeStability(LocalDateTime.now().minusMonths(24), 10, 0, 3).score); + } + + @Test void growth_noData_shouldBeInsufficient() { + assertEquals("INSUFFICIENT_DATA", calculator.computeGrowth(0, null, 0).status); + } + + @Test void growth_active_shouldScore85() { + assertEquals(new BigDecimal("85.00"), + calculator.computeGrowth(2, LocalDateTime.now().minusDays(30), 6).score); + } + + @Test void integrity_positive_shouldScore95() { + assertEquals(new BigDecimal("95.00"), + calculator.computeIntegrity(1, 0, 3, 4.5, true).score); + } + + @Test void activity_active_shouldScore100() { + assertEquals(new BigDecimal("100.00"), calculator.computeActivity(6, 4, 2).score); + } + + // ── 精度 ────────────────────────────────────────────────── + + @Test void education_precision_scale2() { + CandidateScoringCalculator.DimensionResult r = calculator.computeEducation( + StudentProfile.builder().userId(STUDENT_ID).education("本科").school("清华大学").build()); + assertEquals(2, r.score.scale()); + } + + // ── overallScoreFromSnapshots(看板复用引擎公式) ─────────── + + @Test void overallScoreFromSnapshots_okEstimatedMean() { + List snaps = List.of( + scoreSnap("EDUCATION", "76.75", "OK"), + scoreSnap("INTERNSHIP", null, "PENDING"), + scoreSnap("COMPLIANCE", "100.00", "OK")); + assertEquals(new BigDecimal("88.38"), calculator.overallScoreFromSnapshots(snaps)); + } + + @Test void overallScoreFromSnapshots_noComputable_shouldBeNull() { + assertNull(calculator.overallScoreFromSnapshots(List.of( + scoreSnap("INTERNSHIP", null, "PENDING"), + scoreSnap("ABILITY", null, "PENDING")))); + assertNull(calculator.overallScoreFromSnapshots(List.of())); + assertNull(calculator.overallScoreFromSnapshots(null)); + } + + @Test void overallScoreFromSnapshots_insufficientExcluded() { + List snaps = List.of( + scoreSnap("EDUCATION", null, "INSUFFICIENT_DATA"), + scoreSnap("COMPLIANCE", "80.00", "OK"), + scoreSnap("GROWTH", null, "INSUFFICIENT_DATA")); + assertEquals(new BigDecimal("80.00"), calculator.overallScoreFromSnapshots(snaps)); + } + + private CandidateDimensionScore scoreSnap(String dim, String score, String status) { + return CandidateDimensionScore.builder().studentUserId(STUDENT_ID).dimension(dim) + .score(score == null ? null : new BigDecimal(score)).status(status) + .period("2026-08").rulesVersion("candidate-scoring-v0.1").build(); + } + + // ── computeAll 无 N+1 ───────────────────────────────────── + + @Test void computeAll_shouldNotNplus1() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(user())); + when(profileRepository.findByUserId(STUDENT_ID)) + .thenReturn(Optional.of(StudentProfile.builder().userId(STUDENT_ID) + .realName("张三").education("本科").school("清华大学") + .skillsJson("[\"Java\",\"Spring\",\"MySQL\",\"Redis\",\"Docker\"]").build())); + when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(fullPref())); + + List results = calculator.computeAll(STUDENT_ID); + assertEquals(8, results.size()); + verify(userRepository, times(1)).findById(any()); + verify(profileRepository, times(1)).findByUserId(any()); + verify(preferenceRepository, times(1)).findByStudentUserId(any()); + verify(jdbcTemplate, times(5)).queryForObject(anyString(), ArgumentMatchers.>any(), any(Object[].class)); + verify(jdbcTemplate, times(1)).queryForObject(anyString(), eq(Long.class), any(Object[].class)); + } + + // ── upsert SQL(原生 ON DUPLICATE KEY UPDATE) ───────────── + + @Test void upsertSnapshots_shouldUseOnDuplicateKeyUpdate() { + CandidateScoringCalculator.DimensionResult r = + calculator.computeEducation(StudentProfile.builder().userId(STUDENT_ID) + .education("本科").school("清华大学").build()); + calculator.upsertSnapshots(STUDENT_ID, List.of(r), "2026-08"); + + ArgumentCaptor sql = ArgumentCaptor.forClass(String.class); + verify(entityManager).createNativeQuery(sql.capture()); + assertTrue(sql.getValue().contains("ON DUPLICATE KEY UPDATE")); + assertTrue(sql.getValue().contains("version=version+1")); + verify(entityManager.createNativeQuery(anyString())).executeUpdate(); + } + + // ── recomputeAndSnapshot ────────────────────────────────── + + @Test void recomputeAndSnapshot_shouldRecomputeAndWritePeriod() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(user())); + when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of( + StudentProfile.builder().userId(STUDENT_ID).education("本科").school("清华大学").build())); + when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); + + CandidateScoringCalculator.DimensionResult r = + calculator.recomputeAndSnapshot(STUDENT_ID, "EDUCATION", "2026-06"); + assertEquals("EDUCATION", r.dimension); + assertEquals(new BigDecimal("76.75"), r.score); + // period 作为绑定参数写入(写 2026-06 周期快照) + verify(queryMock).setParameter(anyInt(), eq("2026-06")); + verify(queryMock).executeUpdate(); + } + + @Test void recomputeAndSnapshot_invalidDimension_should400() { + org.junit.jupiter.api.function.Executable ex = () -> calculator.recomputeAndSnapshot(STUDENT_ID, "BOGUS", "2026-08"); + assertThrows(com.mydreamjob.common.exception.BusinessException.class, ex); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringControllerTest.java new file mode 100644 index 0000000..bf364ec --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringControllerTest.java @@ -0,0 +1,200 @@ +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.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.CompanyService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class CandidateScoringControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + @MockBean private CandidateScoringEngineService scoringEngineService; + @MockBean private CompanyService companyService; + @MockBean private RedisTemplate redisTemplate; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + doNothing().when(companyService).assertStudentRole(100L); + doThrow(new BusinessException(ErrorCode.FORBIDDEN)).when(companyService).assertStudentRole(200L); + doThrow(new BusinessException(ErrorCode.FORBIDDEN)).when(companyService).assertStudentRole(300L); + } + + private String studentToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "13800138000", Map.of("roles", "STUDENT")); + } + + private String companyToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(200L, "13800138001", Map.of("roles", "COMPANY")); + } + + private String adminToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken(300L, "13800138002", Map.of("roles", "ADMIN")); + } + + private CandidateScoringOverviewResponse buildOverview() { + return CandidateScoringOverviewResponse.builder() + .candidateId(100L).status("PARTIAL").overallScore(new BigDecimal("92.79")) + .weights(Map.of("EDUCATION", new BigDecimal("12.50"))) + .dimensions(List.of(CandidateScoringOverviewResponse.DimensionItem.builder() + .dimension("EDUCATION").score(new BigDecimal("76.75")).status("OK") + .weight(new BigDecimal("12.50")).summary("学历与学校").build())) + .rulesVersion("candidate-scoring-v0.1").generatedAt(Instant.now()).build(); + } + + private CompanyCandidateScoringResponse buildCompanyScoring() { + return CompanyCandidateScoringResponse.builder() + .applicationId(5L).overallScore(new BigDecimal("92.79")).status("PARTIAL") + .dimensions(List.of(CompanyCandidateScoringResponse.CompanyDimensionItem.builder() + .dimension("EDUCATION").score(new BigDecimal("76.75")).status("OK").build())) + .generatedAt(Instant.now()).rulesVersion("candidate-scoring-v0.1").build(); + } + + // ── 综合评分卡(学生)───────────────────────────────────── + + @Test void getOverview_student_shouldReturn200() throws Exception { + when(scoringEngineService.getOverview(100L)).thenReturn(buildOverview()); + mockMvc.perform(get("/profile/me/candidate-scoring").header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.rulesVersion").value("candidate-scoring-v0.1")) + .andExpect(jsonPath("$.data.overallScore").value(92.79)); + } + + @Test void getOverview_noToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/profile/me/candidate-scoring")).andExpect(status().isUnauthorized()); + } + + @Test void getOverview_company_shouldReturn403() throws Exception { + mockMvc.perform(get("/profile/me/candidate-scoring").header("Authorization", companyToken())) + .andExpect(status().isForbidden()); + } + + @Test void getOverview_admin_shouldReturn403() throws Exception { + mockMvc.perform(get("/profile/me/candidate-scoring").header("Authorization", adminToken())) + .andExpect(status().isForbidden()); + } + + // ── 单维度明细 ──────────────────────────────────────────── + + @Test void getDimension_student_shouldReturn200() throws Exception { + when(scoringEngineService.getDimensionDetail(eq(100L), eq("EDUCATION"))) + .thenReturn(DimensionScoreDetailResponse.builder().dimension("EDUCATION") + .score(new BigDecimal("76.75")).status("OK") + .subScores(List.of()).reasons(List.of()).dataSources(List.of()) + .rulesVersion("candidate-scoring-v0.1").build()); + mockMvc.perform(get("/profile/me/candidate-scoring/dimensions/EDUCATION") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.dimension").value("EDUCATION")); + } + + @Test void getDimension_invalidDimension_shouldReturn400() throws Exception { + when(scoringEngineService.getDimensionDetail(eq(100L), eq("BOGUS"))) + .thenThrow(new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension")); + mockMvc.perform(get("/profile/me/candidate-scoring/dimensions/BOGUS") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()); + } + + @Test void getDimension_company_shouldReturn403() throws Exception { + mockMvc.perform(get("/profile/me/candidate-scoring/dimensions/EDUCATION") + .header("Authorization", companyToken())) + .andExpect(status().isForbidden()); + } + + // ── 趋势 ────────────────────────────────────────────────── + + @Test void getTrend_student_shouldReturn200() throws Exception { + when(scoringEngineService.getTrend(eq(100L), eq(6))) + .thenReturn(CandidateScoringTrendResponse.builder().candidateId(100L).months(6) + .items(List.of(CandidateScoringTrendResponse.TrendItem.builder() + .period("2026-07").overallScore(new BigDecimal("88.38")) + .rulesVersion("candidate-scoring-v0.1") + .dimensions(Map.of("EDUCATION", new BigDecimal("76.75"))).build())) + .build()); + mockMvc.perform(get("/profile/me/candidate-scoring/trend").header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].period").value("2026-07")); + } + + @Test void getTrend_monthsOutOfRange_shouldReturn400() throws Exception { + when(scoringEngineService.getTrend(eq(100L), eq(0))) + .thenThrow(new BusinessException(ErrorCode.BAD_REQUEST, "months must be between 1 and 24")); + mockMvc.perform(get("/profile/me/candidate-scoring/trend").param("months", "0") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()); + } + + @Test void getTrend_noToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/profile/me/candidate-scoring/trend")).andExpect(status().isUnauthorized()); + } + + // ── 企业评分卡 ──────────────────────────────────────────── + + @Test void getCompanyScoring_success_shouldReturn200() throws Exception { + when(scoringEngineService.getCompanyScoring(200L, 5L)).thenReturn(buildCompanyScoring()); + mockMvc.perform(get("/company/candidates/5/scoring").header("Authorization", companyToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.applicationId").value(5)) + .andExpect(jsonPath("$.data.rulesVersion").value("candidate-scoring-v0.1")) + .andExpect(content().string(not(containsString("studentUserId")))) + .andExpect(content().string(not(containsString("candidateId")))) + .andExpect(content().string(not(containsString("phone")))) + .andExpect(content().string(not(containsString("email")))) + .andExpect(content().string(not(containsString("resumeUrl")))); + } + + @Test void getCompanyScoring_crossCompany_shouldReturn403() throws Exception { + when(scoringEngineService.getCompanyScoring(200L, 5L)) + .thenThrow(new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH)); + mockMvc.perform(get("/company/candidates/5/scoring").header("Authorization", companyToken())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(8004)); + } + + @Test void getCompanyScoring_appNotFound_shouldReturn404() throws Exception { + when(scoringEngineService.getCompanyScoring(200L, 999L)) + .thenThrow(new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); + mockMvc.perform(get("/company/candidates/999/scoring").header("Authorization", companyToken())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(8002)); + } + + @Test void getCompanyScoring_noToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/company/candidates/5/scoring")).andExpect(status().isUnauthorized()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java new file mode 100644 index 0000000..ea03ff1 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java @@ -0,0 +1,201 @@ +package com.mydreamjob.candidatescore; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +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.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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CandidateScoringEngineServiceTest { + + @Mock private CandidateScoringCalculator calculator; + @Mock private CandidateDimensionScoreRepository snapshotRepository; + @Mock private CompanyRepository companyRepository; + @Mock private ApplicationRepository applicationRepository; + @Mock private JobService jobService; + @InjectMocks private CandidateScoringEngineService service; + + private static final Long STUDENT_ID = 2L; + private static final Long COMPANY_USER_ID = 3L; + private static final Long APP_ID = 5L; + + private CandidateScoringCalculator.DimensionResult dim(String name, String score, String status) { + CandidateScoringCalculator.DimensionResult r = new CandidateScoringCalculator.DimensionResult(name); + r.score = score == null ? null : new BigDecimal(score); + r.status = status; + return r; + } + + private List sixComputable() { + return List.of( + dim("EDUCATION", "76.75", "OK"), + dim("INTERNSHIP", null, "PENDING"), + dim("ABILITY", null, "PENDING"), + dim("COMPLIANCE", "100.00", "OK"), + dim("STABILITY", "100.00", "OK"), + dim("GROWTH", "85.00", "OK"), + dim("INTEGRITY", "95.00", "OK"), + dim("ACTIVITY", "90.00", "OK")); + } + + @BeforeEach + void setUp() { + doNothing().when(calculator).upsertSnapshots(anyLong(), anyList(), anyString()); + } + + // ── 综合评分卡 ────────────────────────────────────────────── + + @Test void getOverview_shouldAssembleAndUpsert() { + when(calculator.computeAll(STUDENT_ID)).thenReturn(sixComputable()); + CandidateScoringOverviewResponse resp = service.getOverview(STUDENT_ID); + + assertEquals(STUDENT_ID, resp.getCandidateId()); + assertEquals("candidate-scoring-v0.1", resp.getRulesVersion()); + assertEquals(new BigDecimal("91.13"), resp.getOverallScore()); // 546.75/6 + assertEquals("PARTIAL", resp.getStatus()); + assertEquals(8, resp.getDimensions().size()); + assertEquals(new BigDecimal("12.50"), resp.getWeights().get("EDUCATION")); + verify(calculator, times(1)).computeAll(STUDENT_ID); + verify(calculator, times(1)).upsertSnapshots(eq(STUDENT_ID), anyList(), anyString()); + } + + @Test void getOverview_allPending_shouldBePending() { + List allPending = List.of( + dim("INTERNSHIP", null, "PENDING"), dim("ABILITY", null, "PENDING")); + when(calculator.computeAll(STUDENT_ID)).thenReturn(allPending); + CandidateScoringOverviewResponse resp = service.getOverview(STUDENT_ID); + assertEquals("PENDING", resp.getStatus()); + assertNull(resp.getOverallScore()); + } + + @Test void getOverview_allInsufficient_shouldBeInsufficient() { + List all = List.of( + dim("EDUCATION", null, "INSUFFICIENT_DATA"), + dim("COMPLIANCE", null, "INSUFFICIENT_DATA"), + dim("INTERNSHIP", null, "PENDING")); + when(calculator.computeAll(STUDENT_ID)).thenReturn(all); + CandidateScoringOverviewResponse resp = service.getOverview(STUDENT_ID); + assertEquals("INSUFFICIENT_DATA", resp.getStatus()); + assertNull(resp.getOverallScore()); + } + + // ── 单维度明细 ──────────────────────────────────────────── + + @Test void getDimensionDetail_invalid_should400() { + when(calculator.computeAll(STUDENT_ID)).thenReturn(sixComputable()); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.getDimensionDetail(STUDENT_ID, "BOGUS")); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test void getDimensionDetail_education_shouldReturnDetail() { + when(calculator.computeAll(STUDENT_ID)).thenReturn(sixComputable()); + DimensionScoreDetailResponse detail = service.getDimensionDetail(STUDENT_ID, "EDUCATION"); + assertEquals("EDUCATION", detail.getDimension()); + assertEquals("candidate-scoring-v0.1", detail.getRulesVersion()); + } + + // ── 趋势 ───────────────────────────────────────────────── + + @Test void getTrend_invalidMonths_should400() { + assertThrows(BusinessException.class, () -> service.getTrend(STUDENT_ID, 0)); + assertThrows(BusinessException.class, () -> service.getTrend(STUDENT_ID, 25)); + } + + @Test void getTrend_shouldNormalizePerPeriod() { + when(snapshotRepository.findTrendFrom(eq(STUDENT_ID), anyString())).thenReturn(List.of( + snapshot("EDUCATION", "76.75", "OK", "2026-07"), + snapshot("COMPLIANCE", "100.00", "OK", "2026-07"), + snapshot("INTERNSHIP", null, "PENDING", "2026-07"), + snapshot("EDUCATION", "80.00", "OK", "2026-08"), + snapshot("COMPLIANCE", "90.00", "OK", "2026-08"))); + CandidateScoringTrendResponse resp = service.getTrend(STUDENT_ID, 2); + assertEquals(2, resp.getItems().size()); + CandidateScoringTrendResponse.TrendItem july = resp.getItems().get(0); + assertEquals("2026-07", july.getPeriod()); + assertEquals(new BigDecimal("88.38"), july.getOverallScore()); + assertEquals(2, july.getDimensions().size()); + assertFalse(july.getDimensions().containsKey("INTERNSHIP")); + assertEquals(new BigDecimal("85.00"), resp.getItems().get(1).getOverallScore()); + } + + private CandidateDimensionScore snapshot(String d, String score, String status, String period) { + return CandidateDimensionScore.builder() + .studentUserId(STUDENT_ID).dimension(d) + .score(score == null ? null : new BigDecimal(score)) + .status(status).period(period).rulesVersion("candidate-scoring-v0.1") + .weight(new BigDecimal("12.50")).version(1L).build(); + } + + // ── 企业评分 ────────────────────────────────────────────── + + @Test void getCompanyScoring_noCompany_should403() { + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.getCompanyScoring(COMPANY_USER_ID, APP_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test void getCompanyScoring_appNotFound_should404() { + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) + .thenReturn(Optional.of(Company.builder().id(1L).ownerUserId(COMPANY_USER_ID).build())); + when(applicationRepository.findById(APP_ID)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.getCompanyScoring(COMPANY_USER_ID, APP_ID)); + assertEquals(ErrorCode.APPLICATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test void getCompanyScoring_otherCompany_should403() { + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) + .thenReturn(Optional.of(Company.builder().id(2L).ownerUserId(COMPANY_USER_ID).build())); + when(applicationRepository.findById(APP_ID)) + .thenReturn(Optional.of(Application.builder().id(APP_ID).jobId(50L).studentUserId(STUDENT_ID).build())); + when(jobService.getJob(50L)).thenReturn(Job.builder().id(50L).companyId(1L).build()); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.getCompanyScoring(COMPANY_USER_ID, APP_ID)); + assertEquals(ErrorCode.APPLICATION_OWNER_MISMATCH.getCode(), ex.getErrorCodeInt()); + } + + @Test void getCompanyScoring_success_whitelistOnly() { + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) + .thenReturn(Optional.of(Company.builder().id(1L).ownerUserId(COMPANY_USER_ID).build())); + when(applicationRepository.findById(APP_ID)) + .thenReturn(Optional.of(Application.builder().id(APP_ID).jobId(50L).studentUserId(STUDENT_ID).build())); + when(jobService.getJob(50L)).thenReturn(Job.builder().id(50L).companyId(1L).build()); + when(calculator.computeAll(STUDENT_ID)).thenReturn(sixComputable()); + + CompanyCandidateScoringResponse resp = service.getCompanyScoring(COMPANY_USER_ID, APP_ID); + assertEquals(APP_ID, resp.getApplicationId()); + assertEquals("candidate-scoring-v0.1", resp.getRulesVersion()); + assertEquals(8, resp.getDimensions().size()); + String json = resp.toString(); + for (String banned : List.of("studentUserId", "candidateId", "phone", "email", "resumeUrl", "passwordHash")) { + assertFalse(json.toLowerCase().contains(banned.toLowerCase())); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java new file mode 100644 index 0000000..2d7da1a --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java @@ -0,0 +1,93 @@ +package com.mydreamjob.candidatescore; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.dto.CandidateScoringOverviewResponse; +import com.mydreamjob.candidatescore.dto.CandidateScoringTrendResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SpringBootTest +@ActiveProfiles("integration-test") +class CandidateScoringPersistenceIntegrationTest { + + @Autowired private CandidateScoringEngineService service; + @Autowired private UserRepository userRepository; + @Autowired private CandidateDimensionScoreRepository snapshotRepository; + @MockBean private RedisTemplate redisTemplate; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + } + + private User createStudent(String phone) { + User u = User.builder().phone(phone).nickname("scoring-test") + .role(User.Role.STUDENT).active(true).verified(true).build(); + u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + return userRepository.save(u); + } + + private String currentPeriod() { + LocalDate d = LocalDate.now(); + return String.format("%04d-%02d", d.getYear(), d.getMonthValue()); + } + + @Test + void upsertSnapshots_shouldBeIdempotentAndIncrementVersion() { + User u = createStudent("13900009901"); + String period = currentPeriod(); + + CandidateScoringOverviewResponse first = service.getOverview(u.getId()); + List rows1 = snapshotRepository.findByStudentUserIdAndPeriod(u.getId(), period); + assertEquals(8, rows1.size()); + // 新用户无数据:全部 INSUFFICIENT_DATA / PENDING,overallScore=null + assertEquals("INSUFFICIENT_DATA", first.getStatus()); + assertNull(first.getOverallScore()); + Long version1 = rows1.get(0).getVersion(); + + // 再次调用:同周期覆盖,version 递增,行数不变 + service.getOverview(u.getId()); + List rows2 = snapshotRepository.findByStudentUserIdAndPeriod(u.getId(), period); + assertEquals(8, rows2.size()); + assertEquals(version1 + 1, rows2.get(0).getVersion()); + } + + @Test + void upsert_rulesVersionPersisted() { + User u = createStudent("13900009902"); + service.getOverview(u.getId()); + List rows = snapshotRepository.findByStudentUserIdAndPeriod(u.getId(), currentPeriod()); + assertTrue(rows.stream().allMatch(r -> "candidate-scoring-v0.1".equals(r.getRulesVersion()))); + assertTrue(rows.stream().allMatch(r -> r.getWeight().doubleValue() == 12.50)); + } + + @Test + void trend_shouldReadSnapshotsOfCurrentPeriod() { + User u = createStudent("13900009903"); + service.getOverview(u.getId()); + CandidateScoringTrendResponse trend = service.getTrend(u.getId(), 6); + assertEquals(1, trend.getItems().size()); + assertEquals(currentPeriod(), trend.getItems().get(0).getPeriod()); + assertNull(trend.getItems().get(0).getOverallScore()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardControllerTest.java new file mode 100644 index 0000000..f589e34 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardControllerTest.java @@ -0,0 +1,179 @@ +package com.mydreamjob.candidatescore.dashboard; + +import com.mydreamjob.candidatescore.dashboard.dto.CandidateRankingItem; +import com.mydreamjob.candidatescore.dashboard.dto.CandidateRankingListResponse; +import com.mydreamjob.candidatescore.dashboard.dto.DashboardOverviewResponse; +import com.mydreamjob.candidatescore.dashboard.dto.JobRankingItem; +import com.mydreamjob.candidatescore.dashboard.dto.JobRankingListResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class CompanyScoringDashboardControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + @MockBean private CompanyScoringDashboardService dashboardService; + @MockBean private RedisTemplate redisTemplate; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + // COMPANY(200) 有企业;STUDENT(100)/ADMIN(300) 无企业 → 服务层 403 + when(dashboardService.getOverview(200L)) + .thenReturn(DashboardOverviewResponse.builder().companyId(1L).totalJobs(5) + .avgJobScore(new BigDecimal("4.50")).totalCandidates(3) + .avgCandidateScore(new BigDecimal("80.00")) + .scoreDistribution(Map.of(1, 0, 2, 0, 3, 1, 4, 2, 5, 2)) + .updatedAt(LocalDateTime.now()).build()); + when(dashboardService.getOverview(100L)).thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + when(dashboardService.getOverview(300L)).thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + } + + private String token(long uid, String role) { + return "Bearer " + jwtTokenProvider.generateAccessToken(uid, "1", Map.of("roles", role)); + } + + @Test void overview_company_should200_andMasked() throws Exception { + mockMvc.perform(get("/company/scoring/dashboard/overview") + .header("Authorization", token(200L, "COMPANY"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalJobs").value(5)) + .andExpect(jsonPath("$.data.avgCandidateScore").value(80.00)) + .andExpect(content().string(not(containsString("phone")))) + .andExpect(content().string(not(containsString("passwordHash")))) + .andExpect(content().string(not(containsString("resumeUrl")))); + } + + @Test void overview_noToken_should401() throws Exception { + mockMvc.perform(get("/company/scoring/dashboard/overview")).andExpect(status().isUnauthorized()); + } + + @Test void overview_student_should403() throws Exception { + mockMvc.perform(get("/company/scoring/dashboard/overview") + .header("Authorization", token(100L, "STUDENT"))) + .andExpect(status().isForbidden()); + } + + @Test void overview_admin_should403() throws Exception { + mockMvc.perform(get("/company/scoring/dashboard/overview") + .header("Authorization", token(300L, "ADMIN"))) + .andExpect(status().isForbidden()); + } + + @Test void jobRankings_company_should200() throws Exception { + when(dashboardService.getJobRankings(eq(200L), eq(0), eq(20))) + .thenReturn(JobRankingListResponse.builder().items(List.of( + JobRankingItem.builder().jobId(1L).title("Java").city("北京") + .avgRating(new BigDecimal("4.50")).reviewCount(2).rank(1).cohortSize(1) + .dimensionAverages(Map.of()).build())) + .total(1).page(0).size(20).totalPages(1).build()); + mockMvc.perform(get("/company/scoring/dashboard/job-rankings") + .header("Authorization", token(200L, "COMPANY"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].jobId").value(1)); + } + + @Test void jobRanking_detail_crossCompany_should400() throws Exception { + // 岗位归属沿用既有契约 JOB_OWNER_MISMATCH(7002) → HTTP 400 + when(dashboardService.getJobRanking(200L, 99L)) + .thenThrow(new BusinessException(ErrorCode.JOB_OWNER_MISMATCH)); + mockMvc.perform(get("/company/scoring/dashboard/job-rankings/99") + .header("Authorization", token(200L, "COMPANY"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(7002)); + } + + @Test void candidateRankings_company_should200() throws Exception { + when(dashboardService.getCandidateRankings(eq(200L), eq(0), eq(20))) + .thenReturn(CandidateRankingListResponse.builder().items(List.of( + CandidateRankingItem.builder().applicationId(30L).jobId(10L) + .candidateName("张三").candidateSchool("清华") + .overallScore(new BigDecimal("90.00")) + .percentile(new BigDecimal("100.00")).sampleCount(1).build())) + .total(1).page(0).size(20).totalPages(1).build()); + mockMvc.perform(get("/company/scoring/dashboard/candidate-rankings") + .header("Authorization", token(200L, "COMPANY"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].candidateName").value("张三")); + } + + @Test void candidateRanking_detail_crossCompany_should403() throws Exception { + when(dashboardService.getCandidateRanking(200L, 5L)) + .thenThrow(new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH)); + mockMvc.perform(get("/company/scoring/dashboard/candidate-rankings/5") + .header("Authorization", token(200L, "COMPANY"))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(8004)); + } + + @Test void trend_company_should200() throws Exception { + when(dashboardService.getTrend(eq(200L), eq(6))) + .thenReturn(com.mydreamjob.candidatescore.dashboard.dto.ScoreTrendResponse.builder() + .companyId(1L).months(6) + .items(List.of(com.mydreamjob.candidatescore.dashboard.dto.ScoreTrendResponse.TrendItem.builder() + .period("2026-08").avgJobScore(new BigDecimal("4.50")) + .avgCandidateScore(new BigDecimal("80.00")).candidateCount(2).build())) + .build()); + mockMvc.perform(get("/company/scoring/dashboard/trend").header("Authorization", token(200L, "COMPANY"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].period").value("2026-08")); + } + + @Test void trend_monthsOutOfRange_should400() throws Exception { + when(dashboardService.getTrend(eq(200L), eq(0))) + .thenThrow(new BusinessException(ErrorCode.BAD_REQUEST, "months must be between 1 and 24")); + mockMvc.perform(get("/company/scoring/dashboard/trend").param("months", "0") + .header("Authorization", token(200L, "COMPANY"))) + .andExpect(status().isBadRequest()); + } + + @Test void adminOverview_admin_should200() throws Exception { + when(dashboardService.getAdminOverview(300L)) + .thenReturn(com.mydreamjob.candidatescore.dashboard.dto.AdminDashboardOverviewResponse.builder() + .totalCompanies(3).totalJobs(15).totalCandidates(22) + .avgCandidateScore(new BigDecimal("70.00")).topCompanies(List.of()).build()); + mockMvc.perform(get("/admin/scoring/dashboard/overview") + .header("Authorization", token(300L, "ADMIN"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalJobs").value(15)); + } + + @Test void adminOverview_company_should403() throws Exception { + when(dashboardService.getAdminOverview(200L)).thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + mockMvc.perform(get("/admin/scoring/dashboard/overview") + .header("Authorization", token(200L, "COMPANY"))) + .andExpect(status().isForbidden()); + } + + @Test void adminOverview_noToken_should401() throws Exception { + mockMvc.perform(get("/admin/scoring/dashboard/overview")).andExpect(status().isUnauthorized()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardPersistenceIntegrationTest.java new file mode 100644 index 0000000..f20804d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardPersistenceIntegrationTest.java @@ -0,0 +1,247 @@ +package com.mydreamjob.candidatescore.dashboard; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.dashboard.dto.CandidateRankingDetailResponse; +import com.mydreamjob.candidatescore.dashboard.dto.CandidateRankingListResponse; +import com.mydreamjob.candidatescore.dashboard.dto.DashboardOverviewResponse; +import com.mydreamjob.candidatescore.dashboard.dto.JobRankingListResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SpringBootTest +@ActiveProfiles("integration-test") +@Transactional +class CompanyScoringDashboardPersistenceIntegrationTest { + + @Autowired private CompanyScoringDashboardService service; + @Autowired private UserRepository userRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private RedisTemplate redisTemplate; + + private static final AtomicInteger COUNTER = new AtomicInteger(0); + + private Long companyUserId; + private Long adminUserId; + private Long studentA; + private Long studentB; + private Long otherCompanyUserId; + private Long companyId; + private Long job1; + private Long job2; + private Long otherJob; + private String period; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + period = String.format("%04d-%02d", LocalDate.now().getYear(), LocalDate.now().getMonthValue()); + + companyUserId = createUser("COMPANY"); + adminUserId = createUser("ADMIN"); + studentA = createUser("STUDENT"); + studentB = createUser("STUDENT"); + Long studentC = createUser("STUDENT"); + otherCompanyUserId = createUser("COMPANY"); + + Company c = Company.builder().name("看板企业" + COUNTER.incrementAndGet()) + .ownerUserId(companyUserId).verified(true).build(); + companyId = companyRepository.save(c).getId(); + Company oc = Company.builder().name("其他企业" + COUNTER.incrementAndGet()) + .ownerUserId(otherCompanyUserId).verified(true).build(); + Long otherCompanyId = companyRepository.save(oc).getId(); + + job1 = insertJob(companyId, "Java 后端"); + job2 = insertJob(companyId, "前端"); + otherJob = insertJob(otherCompanyId, "其他岗位"); + + // 投递:A 有 2 条(去重),B 1 条;另有 1 条 WITHDRAWN(排除) + insertApplication(studentA, job1, "SUBMITTED"); // app1 + insertApplication(studentA, job2, "VIEWED"); // app2(A 的代表投递) + insertApplication(studentB, job1, "SUBMITTED"); // app3 + insertApplication(studentB, otherJob, "WITHDRAWN"); // 排除 + + // 快照:A COMPLIANCE=100 OK;B COMPLIANCE=80 OK、INTERNSHIP PENDING + insertSnapshot(studentA, "COMPLIANCE", "100.00", "OK"); + insertSnapshot(studentB, "COMPLIANCE", "80.00", "OK"); + insertSnapshot(studentB, "INTERNSHIP", null, "PENDING"); + + // 岗位评价:job1 2 条审核通过(5,4)+1 条被拒(studentC,排除);job2 无 + insertReview(job1, studentA, 5, "PUBLISHED", "APPROVED"); + insertReview(job1, studentB, 4, "PUBLISHED", "AUTO_APPROVED"); + insertReview(job1, studentC, 1, "PUBLISHED", "REJECTED"); + } + + private Long createUser(String role) { + User u = User.builder().phone("1390009" + String.format("%04d", COUNTER.incrementAndGet())) + .nickname("t").role(User.Role.valueOf(role)).active(true).verified(true).build(); + u.setRoles(new HashSet<>(Set.of(User.Role.valueOf(role)))); + return userRepository.save(u).getId(); + } + + private Long insertJob(Long companyId, String title) { + jdbcTemplate.update("INSERT INTO jobs (company_id, title, description, city, employment_type, status, created_at, updated_at) " + + "VALUES (?,?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", + companyId, title, "desc", "北京", "FULL_TIME", "PUBLISHED"); + return jdbcTemplate.queryForObject("SELECT MAX(id) FROM jobs", Long.class); + } + + private void insertApplication(Long student, Long job, String status) { + jdbcTemplate.update("INSERT INTO applications (job_id, student_user_id, status, created_at, updated_at) " + + "VALUES (?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", job, student, status); + } + + private void insertSnapshot(Long student, String dim, String score, String status) { + jdbcTemplate.update("INSERT INTO candidate_dimension_scores " + + "(student_user_id, dimension, period, score, status, weight, rules_version, calculated_at, version, created_at, updated_at) " + + "VALUES (?,?,?,?,?,12.50,'candidate-scoring-v0.1',CURRENT_TIMESTAMP,0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", + student, dim, period, score, status); + } + + private void insertReview(Long job, Long student, int rating, String status, String modStatus) { + jdbcTemplate.update("INSERT INTO job_reviews (job_id, student_user_id, rating, content, status, moderation_status, created_at, updated_at) " + + "VALUES (?,?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", job, student, rating, "c", status, modStatus); + } + + // ── 概览 ──────────────────────────────────────────────────── + + @Test void overview_shouldAggregateWithReviewFilter() { + DashboardOverviewResponse r = service.getOverview(companyUserId); + assertEquals(2, r.getTotalJobs()); + assertEquals(new BigDecimal("4.50"), r.getAvgJobScore()); // (5+4)/2,被拒评价不计入 + assertEquals(2, r.getTotalCandidates()); // A、B 去重,WITHDRAWN 排除 + assertEquals(new BigDecimal("90.00"), r.getAvgCandidateScore()); // (100+80)/2 + assertEquals(1, r.getScoreDistribution().get(4)); + assertEquals(1, r.getScoreDistribution().get(5)); + } + + // ── 岗位排名 ──────────────────────────────────────────────── + + @Test void jobRankings_shouldFilterAndStableSort() { + JobRankingListResponse resp = service.getJobRankings(companyUserId, 0, 20); + assertEquals(2, resp.getTotal()); + // 稳定排序 avgRating DESC, jobId DESC:job1(4.5, 较小jobId) 在前 + assertEquals(job1, resp.getItems().get(0).getJobId()); + assertEquals(new BigDecimal("4.50"), resp.getItems().get(0).getAvgRating()); + assertEquals(1, resp.getItems().get(0).getRank()); + assertEquals(2, resp.getItems().get(0).getCohortSize()); + // job2 无评价 → avgRating null,排在末尾 + assertNull(resp.getItems().get(1).getAvgRating()); + } + + @Test void jobRanking_detail_shouldMatchListRank() { + // 有评价岗位:avg 4.50, rank 1 + com.mydreamjob.candidatescore.dashboard.dto.JobRankingItem r1 = + service.getJobRanking(companyUserId, job1); + assertEquals(job1, r1.getJobId()); + assertEquals(new BigDecimal("4.50"), r1.getAvgRating()); + assertEquals(2, r1.getReviewCount()); + assertEquals(1, r1.getRank()); + assertEquals(2, r1.getCohortSize()); + // 无评价岗位:avgRating null,排末尾 → rank 2 + com.mydreamjob.candidatescore.dashboard.dto.JobRankingItem r2 = + service.getJobRanking(companyUserId, job2); + assertNull(r2.getAvgRating()); + assertEquals(2, r2.getRank()); + assertEquals(2, r2.getCohortSize()); + } + + // ── 候选人排名 ────────────────────────────────────────────── + + @Test void candidateRankings_dedupPercentileAndSort() { + CandidateRankingListResponse resp = service.getCandidateRankings(companyUserId, 0, 20); + assertEquals(2, resp.getTotal()); // A 多投递去重 + // 稳定排序 overallScore DESC, applicationId DESC + assertEquals(new BigDecimal("100.00"), resp.getItems().get(0).getOverallScore()); // A + assertEquals(new BigDecimal("100.00"), resp.getItems().get(0).getPercentile()); + assertEquals(new BigDecimal("80.00"), resp.getItems().get(1).getOverallScore()); // B + assertEquals(new BigDecimal("0.00"), resp.getItems().get(1).getPercentile()); + } + + @Test void candidateRanking_detail_shouldIncludeDimensions() { + // studentA 的代表投递是最大 application id(job2 的 VIEWED) + Long repApp = jdbcTemplate.queryForObject( + "SELECT MAX(id) FROM applications WHERE student_user_id=?", Long.class, studentA); + CandidateRankingDetailResponse detail = service.getCandidateRanking(companyUserId, repApp); + assertEquals(new BigDecimal("100.00"), detail.getOverallScore()); + assertEquals(1, detail.getDimensionScores().size()); // 仅 COMPLIANCE OK + assertEquals("COMPLIANCE", detail.getDimensionScores().get(0).getDimension()); + } + + @Test void candidateRanking_crossCompany_should403() { + Long otherApp = jdbcTemplate.queryForObject( + "SELECT MAX(id) FROM applications WHERE student_user_id=? AND job_id=?", Long.class, studentB, otherJob); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.getCandidateRanking(companyUserId, otherApp)); + assertEquals(ErrorCode.APPLICATION_OWNER_MISMATCH.getCode(), ex.getErrorCodeInt()); + } + + @Test void candidateRanking_notFound_should404() { + assertEquals(ErrorCode.APPLICATION_NOT_FOUND.getCode(), + assertThrows(BusinessException.class, + () -> service.getCandidateRanking(companyUserId, 999999L)).getErrorCodeInt()); + } + + // ── 趋势 / admin 概览 ─────────────────────────────────────── + + @Test void trend_shouldReturnMonths() { + var trend = service.getTrend(companyUserId, 6); + assertFalse(trend.getItems().isEmpty()); + assertEquals(new BigDecimal("4.50"), trend.getItems().stream() + .filter(i -> i.getPeriod().equals(period)).findFirst().orElseThrow().getAvgJobScore()); + assertEquals(new BigDecimal("90.00"), trend.getItems().stream() + .filter(i -> i.getPeriod().equals(period)).findFirst().orElseThrow().getAvgCandidateScore()); + } + + @Test void adminOverview_adminOnly() { + assertEquals(ErrorCode.FORBIDDEN.getCode(), + assertThrows(BusinessException.class, () -> service.getAdminOverview(companyUserId)).getErrorCodeInt()); + var overview = service.getAdminOverview(adminUserId); + assertTrue(overview.getTotalCompanies() >= 2); + assertTrue(overview.getTotalJobs() >= 3); + } + + // ── 业务表只读 ────────────────────────────────────────────── + + @Test void dashboard_shouldBeReadOnly() { + long jobs = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM jobs", Long.class); + long apps = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM applications", Long.class); + long reviews = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM job_reviews", Long.class); + long snaps = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM candidate_dimension_scores", Long.class); + service.getOverview(companyUserId); + service.getJobRankings(companyUserId, 0, 20); + service.getCandidateRankings(companyUserId, 0, 20); + service.getTrend(companyUserId, 6); + assertEquals(jobs, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM jobs", Long.class)); + assertEquals(apps, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM applications", Long.class)); + assertEquals(reviews, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM job_reviews", Long.class)); + assertEquals(snaps, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM candidate_dimension_scores", Long.class)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceControllerTest.java new file mode 100644 index 0000000..6b1d008 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceControllerTest.java @@ -0,0 +1,185 @@ +package com.mydreamjob.candidatescore.governance; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.candidatescore.governance.dto.AppealHistoryItem; +import com.mydreamjob.candidatescore.governance.dto.AppealResponse; +import com.mydreamjob.candidatescore.governance.dto.CreateAppealRequest; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class CandidateScoringGovernanceControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider; + @MockBean private CandidateScoringGovernanceService governanceService; + @MockBean private RedisTemplate redisTemplate; + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + } + + private String token(long uid, String role) { + return "Bearer " + jwtTokenProvider.generateAccessToken(uid, "1", Map.of("roles", role)); + } + + private AppealResponse buildAppeal(Long id, String status) { + return AppealResponse.builder().appealId(id).candidateId(100L) + .dimension("EDUCATION").period("2026-08").status(status).reason("分数不合理") + .scoreAtAppeal(new BigDecimal("60.00")).rulesVersion("candidate-scoring-v0.1") + .reviewerId(status.equals("PENDING") ? null : 300L).build(); + } + + @Test void createAppeal_student_should200() throws Exception { + when(governanceService.createAppeal(eq(100L), any(), any())) + .thenReturn(buildAppeal(1L, "PENDING")); + mockMvc.perform(post("/profile/me/candidate-scoring/appeals") + .header("Authorization", token(100L, "STUDENT")) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(Map.of("dimension", "EDUCATION", + "period", "2026-08", "reason", "分数不合理")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PENDING")); + } + + @Test void createAppeal_noToken_should401() throws Exception { + mockMvc.perform(post("/profile/me/candidate-scoring/appeals") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(Map.of("dimension", "EDUCATION", + "period", "2026-08", "reason", "x")))) + .andExpect(status().isUnauthorized()); + } + + @Test void createAppeal_company_should403() throws Exception { + when(governanceService.createAppeal(eq(200L), any(), any())) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + mockMvc.perform(post("/profile/me/candidate-scoring/appeals") + .header("Authorization", token(200L, "COMPANY")) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(Map.of("dimension", "EDUCATION", + "period", "2026-08", "reason", "x")))) + .andExpect(status().isForbidden()); + } + + @Test void listMyAppeals_student_should200() throws Exception { + when(governanceService.listMyAppeals(eq(100L), isNull(), anyInt(), anyInt())) + .thenReturn(org.springframework.data.domain.Page.empty()); + mockMvc.perform(get("/profile/me/candidate-scoring/appeals") + .header("Authorization", token(100L, "STUDENT"))) + .andExpect(status().isOk()); + } + + @Test void getAppeal_student_should200() throws Exception { + when(governanceService.getAppeal(100L, 1L)).thenReturn(buildAppeal(1L, "PENDING")); + mockMvc.perform(get("/profile/me/candidate-scoring/appeals/1") + .header("Authorization", token(100L, "STUDENT"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.appealId").value(1)); + } + + @Test void cancelAppeal_student_should200() throws Exception { + when(governanceService.cancelAppeal(eq(100L), eq(1L), any())) + .thenReturn(buildAppeal(1L, "CANCELLED")); + mockMvc.perform(post("/profile/me/candidate-scoring/appeals/1/cancel") + .header("Authorization", token(100L, "STUDENT"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("CANCELLED")); + } + + @Test void adminDecide_should200() throws Exception { + when(governanceService.decideAppeal(eq(300L), eq(1L), any(), any())) + .thenReturn(buildAppeal(1L, "APPROVED")); + mockMvc.perform(post("/admin/candidate-scoring/appeals/1/decision") + .header("Authorization", token(300L, "ADMIN")) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(Map.of("decision", "APPROVE")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("APPROVED")); + } + + @Test void adminRollback_should200() throws Exception { + when(governanceService.rollbackAppeal(eq(300L), eq(1L), any())) + .thenReturn(buildAppeal(1L, "ROLLED_BACK")); + mockMvc.perform(post("/admin/candidate-scoring/appeals/1/rollback") + .header("Authorization", token(300L, "ADMIN"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ROLLED_BACK")); + } + + @Test void adminList_should200() throws Exception { + when(governanceService.listAllAppeals(eq(300L), isNull(), anyInt(), anyInt())) + .thenReturn(org.springframework.data.domain.Page.empty()); + mockMvc.perform(get("/admin/candidate-scoring/appeals") + .header("Authorization", token(300L, "ADMIN"))) + .andExpect(status().isOk()); + } + + @Test void adminHistory_should200() throws Exception { + when(governanceService.getAppealHistory(300L, 1L)).thenReturn(List.of( + AppealHistoryItem.builder().id(1L).fromStatus(null).toStatus("PENDING") + .actorRole("STUDENT").reason("r").createdAt(LocalDateTime.now()).build())); + mockMvc.perform(get("/admin/candidate-scoring/appeals/1/history") + .header("Authorization", token(300L, "ADMIN"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].toStatus").value("PENDING")); + } + + @Test void adminSnapshotHistory_should200() throws Exception { + mockMvc.perform(get("/admin/candidate-scoring/snapshots/100/EDUCATION/2026-08/history") + .header("Authorization", token(300L, "ADMIN"))) + .andExpect(status().isOk()); + } + + @Test void adminEndpoints_noToken_should401() throws Exception { + mockMvc.perform(get("/admin/candidate-scoring/appeals")).andExpect(status().isUnauthorized()); + mockMvc.perform(post("/admin/candidate-scoring/appeals/1/decision") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(Map.of("decision", "APPROVE")))) + .andExpect(status().isUnauthorized()); + } + + @Test void createAppeal_response_shouldNotLeakSensitive() throws Exception { + when(governanceService.createAppeal(eq(100L), any(), any())) + .thenReturn(buildAppeal(1L, "PENDING")); + mockMvc.perform(post("/profile/me/candidate-scoring/appeals") + .header("Authorization", token(100L, "STUDENT")) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(Map.of("dimension", "EDUCATION", + "period", "2026-08", "reason", "x")))) + .andExpect(status().isOk()) + .andExpect(content().string(not(containsString("phone")))) + .andExpect(content().string(not(containsString("passwordHash")))) + .andExpect(content().string(not(containsString("resumeUrl")))) + .andExpect(content().string(not(containsString("email")))); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernancePersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernancePersistenceIntegrationTest.java new file mode 100644 index 0000000..dab2628 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernancePersistenceIntegrationTest.java @@ -0,0 +1,150 @@ +package com.mydreamjob.candidatescore.governance; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.governance.dto.AppealDecisionRequest; +import com.mydreamjob.candidatescore.governance.dto.AppealResponse; +import com.mydreamjob.candidatescore.governance.dto.CreateAppealRequest; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SpringBootTest +@ActiveProfiles("integration-test") +class CandidateScoringGovernancePersistenceIntegrationTest { + + @Autowired private CandidateScoringGovernanceService service; + @Autowired private CandidateScoringAppealRepository appealRepository; + @Autowired private CandidateScoringAppealHistoryRepository appealHistoryRepository; + @Autowired private CandidateScoringSnapshotHistoryRepository snapshotHistoryRepository; + @Autowired private CandidateScoringRollbackLimitRepository rollbackLimitRepository; + @Autowired private UserRepository userRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private RedisTemplate redisTemplate; + + private static final AtomicInteger COUNTER = new AtomicInteger(0); + + private Long studentId; + private Long adminId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + // H2 上下文跨用例共享,使用唯一手机号避免 users.phone 唯一冲突 + studentId = createUser(uniquePhone(), User.Role.STUDENT); + adminId = createUser(uniquePhone(), User.Role.ADMIN); + } + + private String uniquePhone() { + return "1390001" + String.format("%04d", COUNTER.incrementAndGet()); + } + + private Long createUser(String phone, User.Role role) { + User u = User.builder().phone(phone).nickname("t").role(role).active(true).verified(true).build(); + u.setRoles(new HashSet<>(Set.of(role))); + return userRepository.save(u).getId(); + } + + private CreateAppealRequest appealReq() { + CreateAppealRequest r = new CreateAppealRequest(); + r.setDimension("EDUCATION"); r.setPeriod("2026-08"); r.setReason("分数不合理"); + return r; + } + + private void approveAndRollback(Long appealId) { + AppealDecisionRequest d = new AppealDecisionRequest(); + d.setDecision("APPROVE"); + service.decideAppeal(adminId, appealId, d, null); + service.rollbackAppeal(adminId, appealId, null); + } + + @Test void activeKey_shouldBlockDuplicatePending() { + AppealResponse first = service.createAppeal(studentId, appealReq(), null); + assertEquals("PENDING", first.getStatus()); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.createAppeal(studentId, appealReq(), null)); + assertEquals(ErrorCode.APPEAL_DUPLICATE.getCode(), ex.getErrorCodeInt()); + } + + @Transactional + @Test void rollback_shouldRecordBeforeAfterAndVersion() { + AppealResponse appeal = service.createAppeal(studentId, appealReq(), null); + approveAndRollback(appeal.getAppealId()); + + var appealEntity = appealRepository.findById(appeal.getAppealId()).orElseThrow(); + assertEquals("ROLLED_BACK", appealEntity.getStatus()); + assertNull(appealEntity.getActiveKey()); + + // 历史:NULL→PENDING→APPROVED→ROLLED_BACK + assertEquals(3, appealHistoryRepository.findByAppealIdOrderByCreatedAtAsc(appeal.getAppealId()).size()); + // 快照历史 1 条(回滚前后) + List sh = + snapshotHistoryRepository.findByAppealIdOrderByCreatedAtAsc(appeal.getAppealId()); + assertEquals(1, sh.size()); + assertEquals("ROLLBACK", sh.get(0).getAction()); + assertNotNull(sh.get(0).getAfterVersion()); + // 限流计数 + var limit = rollbackLimitRepository.findByStudentUserIdAndRollbackYear(studentId, 2026).orElseThrow(); + assertEquals(1, limit.getRollbackCount()); + + // 重复回滚幂等:不再新增快照历史 + service.rollbackAppeal(adminId, appeal.getAppealId(), null); + assertEquals(1, snapshotHistoryRepository.findByAppealIdOrderByCreatedAtAsc(appeal.getAppealId()).size()); + } + + @Transactional + @Test void rollback_fourthPerYear_should8605() { + for (int i = 0; i < 3; i++) { + AppealResponse a = service.createAppeal(studentId, appealReq(), null); + approveAndRollback(a.getAppealId()); + } + AppealResponse fourth = service.createAppeal(studentId, appealReq(), null); + AppealDecisionRequest d4 = new AppealDecisionRequest(); + d4.setDecision("APPROVE"); + service.decideAppeal(adminId, fourth.getAppealId(), d4, null); // 第 4 次 APPROVE 成功 + BusinessException ex = assertThrows(BusinessException.class, + () -> service.rollbackAppeal(adminId, fourth.getAppealId(), null)); // 第 4 次回滚 → 8605 + assertEquals(ErrorCode.APPEAL_ROLLBACK_LIMIT_EXCEEDED.getCode(), ex.getErrorCodeInt()); + // 计数保持 3 + assertEquals(3, rollbackLimitRepository.findByStudentUserIdAndRollbackYear(studentId, 2026).orElseThrow().getRollbackCount()); + } + + @Test void governance_shouldNotTouchBusinessTables() { + long jobs = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM jobs", Long.class); + long apps = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM applications", Long.class); + long profiles = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM student_profiles", Long.class); + long offers = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM offers", Long.class); + long feedback = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM candidate_feedback", Long.class); + + AppealResponse a = service.createAppeal(studentId, appealReq(), null); + approveAndRollback(a.getAppealId()); + + assertEquals(jobs, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM jobs", Long.class)); + assertEquals(apps, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM applications", Long.class)); + assertEquals(profiles, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM student_profiles", Long.class)); + assertEquals(offers, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM offers", Long.class)); + assertEquals(feedback, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM candidate_feedback", Long.class)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceServiceTest.java new file mode 100644 index 0000000..b2571eb --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceServiceTest.java @@ -0,0 +1,331 @@ +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.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.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CandidateScoringGovernanceServiceTest { + + @Mock private CandidateScoringAppealRepository appealRepository; + @Mock private CandidateScoringAppealHistoryRepository appealHistoryRepository; + @Mock private CandidateScoringSnapshotHistoryRepository snapshotHistoryRepository; + @Mock private CandidateScoringRollbackLimitRepository rollbackLimitRepository; + @Mock private CandidateDimensionScoreRepository snapshotRepository; + @Mock private CandidateScoringCalculator calculator; + @Mock private UserRepository userRepository; + @Mock private AuditService auditService; + @Mock private EntityManager entityManager; + @InjectMocks private CandidateScoringGovernanceService service; + + private static final Long STUDENT_ID = 2L; + private static final Long ADMIN_ID = 1L; + private static final Long OTHER_STUDENT_ID = 9L; + private final AuditContext ctx = AuditContext.builder().requestId("r1").build(); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "entityManager", entityManager); + Query nativeQ = mock(Query.class); + when(nativeQ.setParameter(anyInt(), any())).thenReturn(nativeQ); + when(entityManager.createNativeQuery(anyString())).thenReturn(nativeQ); + mockRole(STUDENT_ID, User.Role.STUDENT); + mockRole(ADMIN_ID, User.Role.ADMIN); + mockRole(OTHER_STUDENT_ID, User.Role.STUDENT); + } + + private void mockRole(Long uid, User.Role role) { + User u = User.builder().id(uid).phone("1").role(role).build(); + u.setRoles(new HashSet<>(Set.of(role))); + when(userRepository.findById(uid)).thenReturn(Optional.of(u)); + } + + private CandidateScoringAppeal appeal(Long id, String status) { + return CandidateScoringAppeal.builder() + .id(id).candidateUserId(STUDENT_ID).dimension("EDUCATION").period("2026-08") + .status(status).reason("分数错误").rulesVersion("candidate-scoring-v0.1") + .activeKey(STUDENT_ID + ":EDUCATION:2026-08").version(0L).build(); + } + + private CreateAppealRequest req() { + CreateAppealRequest r = new CreateAppealRequest(); + r.setDimension("EDUCATION"); r.setPeriod("2026-08"); r.setReason("分数不合理"); + return r; + } + + // ── 创建 ───────────────────────────────────────────────── + + @Test void createAppeal_shouldCreatePending() { + CandidateScoringAppeal saved = appeal(1L, CandidateScoringAppeal.STATUS_PENDING); + when(appealRepository.findByActiveKey(any())).thenReturn(Optional.empty()); + when(appealRepository.findByCandidateUserIdAndIdempotencyKey(any(), any())).thenReturn(Optional.empty()); + when(snapshotRepository.findByStudentUserIdAndPeriod(STUDENT_ID, "2026-08")) + .thenReturn(List.of(CandidateDimensionScore.builder().dimension("EDUCATION") + .score(new BigDecimal("76.75")).version(2L).period("2026-08") + .studentUserId(STUDENT_ID).status("OK").rulesVersion("candidate-scoring-v0.1").build())); + when(appealRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + + AppealResponse resp = service.createAppeal(STUDENT_ID, req(), ctx); + assertEquals(CandidateScoringAppeal.STATUS_PENDING, resp.getStatus()); + assertEquals(new BigDecimal("76.75"), resp.getScoreAtAppeal()); + assertEquals(2L, resp.getSnapshotVersionAtAppeal()); + verify(appealHistoryRepository).save(any()); + verify(auditService).log(eq(STUDENT_ID), eq("APPEAL_SUBMIT"), eq("CANDIDATE_SCORING_APPEAL"), any(), any(), any()); + } + + @Test void createAppeal_invalidDimension_should400() { + CreateAppealRequest r = req(); r.setDimension("BOGUS"); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), + assertThrows(BusinessException.class, () -> service.createAppeal(STUDENT_ID, r, ctx)).getErrorCodeInt()); + } + + @Test void createAppeal_invalidPeriod_should400() { + CreateAppealRequest r = req(); r.setPeriod("2026-13"); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), + assertThrows(BusinessException.class, () -> service.createAppeal(STUDENT_ID, r, ctx)).getErrorCodeInt()); + } + + @Test void createAppeal_missingReason_should8604() { + CreateAppealRequest r = req(); r.setReason(" "); + assertEquals(ErrorCode.APPEAL_REASON_REQUIRED.getCode(), + assertThrows(BusinessException.class, () -> service.createAppeal(STUDENT_ID, r, ctx)).getErrorCodeInt()); + } + + @Test void createAppeal_duplicateActive_should8602() { + when(appealRepository.findByActiveKey(any())).thenReturn(Optional.of(appeal(1L, "PENDING"))); + assertEquals(ErrorCode.APPEAL_DUPLICATE.getCode(), + assertThrows(BusinessException.class, () -> service.createAppeal(STUDENT_ID, req(), ctx)).getErrorCodeInt()); + } + + @Test void createAppeal_sameIdempotencyKeySameBody_shouldReturnExisting() { + CandidateScoringAppeal existing = appeal(5L, CandidateScoringAppeal.STATUS_PENDING); + existing.setReason("分数不合理"); // 与 req() 一致 + when(appealRepository.findByCandidateUserIdAndIdempotencyKey(STUDENT_ID, "K")).thenReturn(Optional.of(existing)); + CreateAppealRequest r = req(); r.setIdempotencyKey("K"); + AppealResponse resp = service.createAppeal(STUDENT_ID, r, ctx); + assertEquals(5L, resp.getAppealId()); + verify(appealRepository, never()).saveAndFlush(any()); + } + + @Test void createAppeal_sameIdempotencyKeyDifferentBody_should8607() { + CandidateScoringAppeal existing = appeal(5L, CandidateScoringAppeal.STATUS_PENDING); + when(appealRepository.findByCandidateUserIdAndIdempotencyKey(STUDENT_ID, "K")).thenReturn(Optional.of(existing)); + CreateAppealRequest r = req(); r.setIdempotencyKey("K"); r.setReason("不同请求体"); + assertEquals(ErrorCode.APPEAL_IDEMPOTENCY_CONFLICT.getCode(), + assertThrows(BusinessException.class, () -> service.createAppeal(STUDENT_ID, r, ctx)).getErrorCodeInt()); + } + + @Test void createAppeal_concurrentUniqueConflict_should8602() { + when(appealRepository.findByActiveKey(any())).thenReturn(Optional.empty()); + when(appealRepository.findByCandidateUserIdAndIdempotencyKey(any(), any())).thenReturn(Optional.empty()); + when(appealRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("dup")); + assertEquals(ErrorCode.APPEAL_DUPLICATE.getCode(), + assertThrows(BusinessException.class, () -> service.createAppeal(STUDENT_ID, req(), ctx)).getErrorCodeInt()); + } + + @Test void createAppeal_byCompany_should403() { + mockRole(3L, User.Role.COMPANY); + assertEquals(ErrorCode.FORBIDDEN.getCode(), + assertThrows(BusinessException.class, () -> service.createAppeal(3L, req(), ctx)).getErrorCodeInt()); + } + + // ── 详情 / 撤销 ─────────────────────────────────────────── + + @Test void getAppeal_notFound_should8601() { + when(appealRepository.findById(99L)).thenReturn(Optional.empty()); + assertEquals(ErrorCode.APPEAL_NOT_FOUND.getCode(), + assertThrows(BusinessException.class, () -> service.getAppeal(STUDENT_ID, 99L)).getErrorCodeInt()); + } + + @Test void getAppeal_otherStudent_should403() { + when(appealRepository.findById(1L)).thenReturn(Optional.of(appeal(1L, "PENDING"))); + assertEquals(ErrorCode.FORBIDDEN.getCode(), + assertThrows(BusinessException.class, () -> service.getAppeal(OTHER_STUDENT_ID, 1L)).getErrorCodeInt()); + } + + @Test void getAppeal_adminSeesReviewer_shouldIncluded() { + CandidateScoringAppeal a = appeal(1L, "APPROVED"); + a.setReviewerId(ADMIN_ID); + when(appealRepository.findById(1L)).thenReturn(Optional.of(a)); + AppealResponse adminView = service.getAppeal(ADMIN_ID, 1L); + assertEquals(ADMIN_ID, adminView.getReviewerId()); + AppealResponse studentView = service.getAppeal(STUDENT_ID, 1L); + assertNull(studentView.getReviewerId()); + } + + @Test void cancelAppeal_success_shouldClearActiveKey() { + CandidateScoringAppeal a = appeal(1L, "PENDING"); + when(appealRepository.findById(1L)).thenReturn(Optional.of(a)); + AppealResponse resp = service.cancelAppeal(STUDENT_ID, 1L, ctx); + assertEquals(CandidateScoringAppeal.STATUS_CANCELLED, resp.getStatus()); + assertNull(a.getActiveKey()); + } + + @Test void cancelAppeal_notPending_should8606() { + when(appealRepository.findById(1L)).thenReturn(Optional.of(appeal(1L, "REJECTED"))); + assertEquals(ErrorCode.APPEAL_INVALID_TRANSITION.getCode(), + assertThrows(BusinessException.class, () -> service.cancelAppeal(STUDENT_ID, 1L, ctx)).getErrorCodeInt()); + } + + // ── 审核 ───────────────────────────────────────────────── + + @Test void decideApprove_shouldWork() { + CandidateScoringAppeal a = appeal(1L, "PENDING"); + when(appealRepository.findById(1L)).thenReturn(Optional.of(a)); + when(appealRepository.saveAndFlush(any())).thenReturn(a); + AppealDecisionRequest req = new AppealDecisionRequest(); req.setDecision("APPROVE"); req.setReason("同意"); + AppealResponse resp = service.decideAppeal(ADMIN_ID, 1L, req, ctx); + assertEquals(CandidateScoringAppeal.STATUS_APPROVED, resp.getStatus()); + assertEquals(ADMIN_ID, resp.getReviewerId()); + } + + @Test void decideReject_missingReason_should8604() { + when(appealRepository.findById(1L)).thenReturn(Optional.of(appeal(1L, "PENDING"))); + AppealDecisionRequest req = new AppealDecisionRequest(); req.setDecision("REJECT"); + assertEquals(ErrorCode.APPEAL_REASON_REQUIRED.getCode(), + assertThrows(BusinessException.class, () -> service.decideAppeal(ADMIN_ID, 1L, req, ctx)).getErrorCodeInt()); + } + + @Test void decide_onNonPending_should8603() { + when(appealRepository.findById(1L)).thenReturn(Optional.of(appeal(1L, "APPROVED"))); + AppealDecisionRequest req = new AppealDecisionRequest(); req.setDecision("REJECT"); req.setReason("x"); + assertEquals(ErrorCode.APPEAL_ALREADY_DECIDED.getCode(), + assertThrows(BusinessException.class, () -> service.decideAppeal(ADMIN_ID, 1L, req, ctx)).getErrorCodeInt()); + } + + @Test void decide_concurrent_optimisticLock_should8606() { + when(appealRepository.findById(1L)).thenReturn(Optional.of(appeal(1L, "PENDING"))); + when(appealRepository.saveAndFlush(any())).thenThrow(new OptimisticLockingFailureException("lock")); + AppealDecisionRequest req = new AppealDecisionRequest(); req.setDecision("APPROVE"); + assertEquals(ErrorCode.APPEAL_INVALID_TRANSITION.getCode(), + assertThrows(BusinessException.class, () -> service.decideAppeal(ADMIN_ID, 1L, req, ctx)).getErrorCodeInt()); + } + + @Test void decide_byStudent_should403() { + assertEquals(ErrorCode.FORBIDDEN.getCode(), + assertThrows(BusinessException.class, () -> service.decideAppeal(STUDENT_ID, 1L, + new AppealDecisionRequest(), ctx)).getErrorCodeInt()); + } + + // ── 回滚 ───────────────────────────────────────────────── + + private void stubRollbackData(CandidateScoringAppeal appeal, int limitCount) { + when(appealRepository.findById(appeal.getId())).thenReturn(Optional.of(appeal)); + CandidateScoringRollbackLimit limit = CandidateScoringRollbackLimit.builder() + .studentUserId(STUDENT_ID).rollbackYear(LocalDateTime.now().getYear()).rollbackCount(limitCount).build(); + when(rollbackLimitRepository.saveAndFlush(any())).thenReturn(limit); + when(rollbackLimitRepository.findByStudentUserIdAndRollbackYear(eq(STUDENT_ID), anyInt())).thenReturn(Optional.of(limit)); + CandidateDimensionScore before = CandidateDimensionScore.builder().dimension(appeal.getDimension()) + .score(new BigDecimal("60.00")).version(2L).period(appeal.getPeriod()) + .studentUserId(STUDENT_ID).status("OK").rulesVersion("candidate-scoring-v0.1").build(); + CandidateDimensionScore afterSnap = CandidateDimensionScore.builder().dimension(appeal.getDimension()) + .score(new BigDecimal("60.00")).version(3L).period(appeal.getPeriod()) + .studentUserId(STUDENT_ID).status("OK").rulesVersion("candidate-scoring-v0.1").build(); + when(snapshotRepository.findByStudentUserIdAndPeriod(STUDENT_ID, appeal.getPeriod())) + .thenReturn(List.of(before), List.of(afterSnap)); // 第一次读回滚前、第二次读回滚后 + CandidateScoringCalculator.DimensionResult after = new CandidateScoringCalculator.DimensionResult(appeal.getDimension()); + after.score = new BigDecimal("80.00"); after.status = "OK"; + when(calculator.recomputeAndSnapshot(eq(STUDENT_ID), eq(appeal.getDimension()), eq(appeal.getPeriod()))).thenReturn(after); + } + + @Test void rollback_notApproved_should8606() { + when(appealRepository.findById(1L)).thenReturn(Optional.of(appeal(1L, "PENDING"))); + assertEquals(ErrorCode.APPEAL_INVALID_TRANSITION.getCode(), + assertThrows(BusinessException.class, () -> service.rollbackAppeal(ADMIN_ID, 1L, ctx)).getErrorCodeInt()); + } + + @Test void rollback_limitExceeded_should8605() { + CandidateScoringAppeal a = appeal(1L, "APPROVED"); + stubRollbackData(a, 3); + assertEquals(ErrorCode.APPEAL_ROLLBACK_LIMIT_EXCEEDED.getCode(), + assertThrows(BusinessException.class, () -> service.rollbackAppeal(ADMIN_ID, 1L, ctx)).getErrorCodeInt()); + } + + @Test void rollback_success_shouldRecordBeforeAfter() { + CandidateScoringAppeal a = appeal(1L, "APPROVED"); + stubRollbackData(a, 1); + AppealResponse resp = service.rollbackAppeal(ADMIN_ID, 1L, ctx); + assertEquals(CandidateScoringAppeal.STATUS_ROLLED_BACK, resp.getStatus()); + // 快照历史记录 before/after + org.mockito.ArgumentCaptor cap = + org.mockito.ArgumentCaptor.forClass(CandidateScoringSnapshotHistory.class); + verify(snapshotHistoryRepository).save(cap.capture()); + assertEquals(new BigDecimal("60.00"), cap.getValue().getBeforeScore()); + assertEquals(new BigDecimal("80.00"), cap.getValue().getAfterScore()); + assertEquals(2L, cap.getValue().getBeforeVersion()); + assertEquals(3L, cap.getValue().getAfterVersion()); + // 限流计数 +1 + verify(rollbackLimitRepository).save(argThat(l -> l.getRollbackCount() == 2)); + verify(appealHistoryRepository).save(argThat(h -> + CandidateScoringAppeal.STATUS_ROLLED_BACK.equals(h.getToStatus()))); + verify(auditService).log(eq(ADMIN_ID), eq("APPEAL_ROLLBACK"), eq("CANDIDATE_SCORING_APPEAL"), any(), any(), any()); + } + + @Test void rollback_alreadyRolledBack_shouldIdempotent() { + when(appealRepository.findById(1L)).thenReturn(Optional.of(appeal(1L, "ROLLED_BACK"))); + AppealResponse resp = service.rollbackAppeal(ADMIN_ID, 1L, ctx); + assertEquals(CandidateScoringAppeal.STATUS_ROLLED_BACK, resp.getStatus()); + verify(snapshotHistoryRepository, never()).save(any()); + verify(rollbackLimitRepository, never()).save(any()); + } + + // ── 历史脱敏 ───────────────────────────────────────────── + + @Test void history_studentNoActorUserId_adminHas() { + CandidateScoringAppealHistory h = CandidateScoringAppealHistory.builder() + .id(1L).appealId(1L).fromStatus(null).toStatus("PENDING") + .actorUserId(STUDENT_ID).actorRole("STUDENT").reason("r").createdAt(LocalDateTime.now()).build(); + when(appealRepository.findById(1L)).thenReturn(Optional.of(appeal(1L, "PENDING"))); + when(appealHistoryRepository.findByAppealIdOrderByCreatedAtAsc(1L)).thenReturn(List.of(h)); + + List studentView = service.getAppealHistory(STUDENT_ID, 1L); + assertNull(studentView.get(0).getActorUserId()); + assertEquals("STUDENT", studentView.get(0).getActorRole()); + + List adminView = service.getAppealHistory(ADMIN_ID, 1L); + assertEquals(STUDENT_ID, adminView.get(0).getActorUserId()); + } + + // ── 快照审计仅 ADMIN ───────────────────────────────────── + + @Test void snapshotHistory_requiresAdmin() { + assertEquals(ErrorCode.FORBIDDEN.getCode(), + assertThrows(BusinessException.class, + () -> service.getSnapshotHistory(STUDENT_ID, STUDENT_ID, "EDUCATION", "2026-08")).getErrorCodeInt()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java new file mode 100644 index 0000000..5770dc1 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java @@ -0,0 +1,45 @@ +package com.mydreamjob.common.exception; + +import com.mydreamjob.common.api.ApiResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 账号注销错误码(9301-9306)→ HTTP 409 映射单元测试。 + */ +class GlobalExceptionHandlerAccountDeletionTest { + + private GlobalExceptionHandler handler; + private MockHttpServletRequest request; + + @BeforeEach + void setUp() { + handler = new GlobalExceptionHandler(); + request = new MockHttpServletRequest(); + request.setRequestURI("/api/v1/profile/me/deletion"); + } + + @Test + void accountDeletionErrorCodes_shouldMapTo409() { + ErrorCode[] deletionCodes = { + ErrorCode.INVALID_DELETION_STATE, + ErrorCode.ACCOUNT_DELETION_PENDING, + ErrorCode.ACCOUNT_DELETED, + ErrorCode.ACCOUNT_DELETION_CONCURRENT_UPDATE, + ErrorCode.ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED, + ErrorCode.LAST_ADMIN_CANNOT_DELETE + }; + for (ErrorCode code : deletionCodes) { + BusinessException ex = new BusinessException(code); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.CONFLICT.value(), resp.getStatusCode().value(), + code + " should map to 409"); + assertEquals(code.getCode(), resp.getBody().getCode()); + } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java index 9fecee8..e21eadf 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java @@ -103,6 +103,22 @@ class GlobalExceptionHandlerHttpStatusTest { assertEquals(ErrorCode.REVIEW_NOT_FOUND.getCode(), resp.getBody().getCode()); } + @Test + void conversationNotFound_shouldReturnHttp404() { + BusinessException ex = new BusinessException(ErrorCode.CONVERSATION_NOT_FOUND); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.NOT_FOUND.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.CONVERSATION_NOT_FOUND.getCode(), resp.getBody().getCode()); + } + + @Test + void privacySettingsConcurrentUpdate_shouldReturnHttp409() { + BusinessException ex = new BusinessException(ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.CONFLICT.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE.getCode(), resp.getBody().getCode()); + } + // ── 400 ─────────────────────────────────────────────────── @Test @@ -113,6 +129,22 @@ class GlobalExceptionHandlerHttpStatusTest { assertEquals(ErrorCode.BAD_REQUEST.getCode(), resp.getBody().getCode()); } + @Test + void interviewOnlineUrlRequired_shouldReturnHttp400() { + BusinessException ex = new BusinessException(ErrorCode.INTERVIEW_ONLINE_URL_REQUIRED); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.BAD_REQUEST.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.INTERVIEW_ONLINE_URL_REQUIRED.getCode(), resp.getBody().getCode()); + } + + @Test + void interviewOfflineAddressRequired_shouldReturnHttp400() { + BusinessException ex = new BusinessException(ErrorCode.INTERVIEW_OFFLINE_ADDRESS_REQUIRED); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.BAD_REQUEST.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.INTERVIEW_OFFLINE_ADDRESS_REQUIRED.getCode(), resp.getBody().getCode()); + } + @Test void unauthorized_shouldReturnHttp400() { BusinessException ex = new BusinessException(ErrorCode.UNAUTHORIZED); @@ -120,4 +152,62 @@ class GlobalExceptionHandlerHttpStatusTest { assertEquals(HttpStatus.BAD_REQUEST.value(), resp.getStatusCode().value()); assertEquals(ErrorCode.UNAUTHORIZED.getCode(), resp.getBody().getCode()); } + + // ── Offer 进阶错误码映射 ───────────────────────────────── + + @Test + void offerCounterOfferNotFound_shouldReturnHttp404() { + BusinessException ex = new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.NOT_FOUND.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND.getCode(), resp.getBody().getCode()); + } + + @Test + void offerCounterOfferExists_shouldReturnHttp409() { + BusinessException ex = new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_EXISTS); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.CONFLICT.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.OFFER_COUNTER_OFFER_EXISTS.getCode(), resp.getBody().getCode()); + } + + @Test + void offerCounterOfferNotPending_shouldReturnHttp409() { + BusinessException ex = new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.CONFLICT.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING.getCode(), resp.getBody().getCode()); + } + + @Test + void offerCounterOfferExpired_shouldReturnHttp409() { + BusinessException ex = new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_EXPIRED); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.CONFLICT.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.OFFER_COUNTER_OFFER_EXPIRED.getCode(), resp.getBody().getCode()); + } + + @Test + void offerNotWithdrawable_shouldReturnHttp400() { + BusinessException ex = new BusinessException(ErrorCode.OFFER_NOT_WITHDRAWABLE); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.BAD_REQUEST.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.OFFER_NOT_WITHDRAWABLE.getCode(), resp.getBody().getCode()); + } + + @Test + void offerCompareTooMany_shouldReturnHttp400() { + BusinessException ex = new BusinessException(ErrorCode.OFFER_COMPARE_TOO_MANY); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.BAD_REQUEST.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.OFFER_COMPARE_TOO_MANY.getCode(), resp.getBody().getCode()); + } + + @Test + void offerCompareNotOwner_shouldReturnHttp403() { + BusinessException ex = new BusinessException(ErrorCode.OFFER_COMPARE_NOT_OWNER); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + assertEquals(HttpStatus.FORBIDDEN.value(), resp.getStatusCode().value()); + assertEquals(ErrorCode.OFFER_COMPARE_NOT_OWNER.getCode(), resp.getBody().getCode()); + } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java new file mode 100644 index 0000000..b37edcb --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java @@ -0,0 +1,90 @@ +package com.mydreamjob.common.exception; + +import com.mydreamjob.common.api.ApiResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * V33 举报与屏蔽错误码的 HTTP 状态映射单元测试。 + * + *

9601→404;9603/9604/9605/9606→409;9609→429; + * 9602/9607/9608→400(默认);屏蔽对外统一经 2002→403。

+ */ +class GlobalExceptionHandlerReportBlockTest { + + private GlobalExceptionHandler handler; + private MockHttpServletRequest request; + + @BeforeEach + void setUp() { + handler = new GlobalExceptionHandler(); + request = new MockHttpServletRequest(); + request.setRequestURI("/api/v1/reports"); + } + + private int statusOf(ErrorCode code) { + BusinessException ex = new BusinessException(code); + ResponseEntity> resp = handler.handleBusinessException(ex, request); + return resp.getStatusCode().value(); + } + + // 404 + @Test + void reportNotFound_shouldReturnHttp404() { + assertEquals(HttpStatus.NOT_FOUND.value(), statusOf(ErrorCode.REPORT_NOT_FOUND)); + } + + // 409 + @Test + void reportDuplicate_shouldReturnHttp409() { + assertEquals(HttpStatus.CONFLICT.value(), statusOf(ErrorCode.REPORT_DUPLICATE)); + } + + @Test + void alreadyDecided_shouldReturnHttp409() { + assertEquals(HttpStatus.CONFLICT.value(), statusOf(ErrorCode.REPORT_ALREADY_DECIDED)); + } + + @Test + void invalidTransition_shouldReturnHttp409() { + assertEquals(HttpStatus.CONFLICT.value(), statusOf(ErrorCode.REPORT_INVALID_TRANSITION)); + } + + @Test + void idempotencyConflict_shouldReturnHttp409() { + assertEquals(HttpStatus.CONFLICT.value(), statusOf(ErrorCode.REPORT_IDEMPOTENCY_CONFLICT)); + } + + // 429 + @Test + void rateLimited_shouldReturnHttp429() { + assertEquals(HttpStatus.TOO_MANY_REQUESTS.value(), statusOf(ErrorCode.REPORT_RATE_LIMITED)); + } + + // 400(默认) + @Test + void cannotReportSelf_shouldReturnHttp400() { + assertEquals(HttpStatus.BAD_REQUEST.value(), statusOf(ErrorCode.CANNOT_REPORT_SELF)); + } + + @Test + void reasonRequired_shouldReturnHttp400() { + assertEquals(HttpStatus.BAD_REQUEST.value(), statusOf(ErrorCode.REPORT_REASON_REQUIRED)); + } + + @Test + void cannotBlockSelf_shouldReturnHttp400() { + assertEquals(HttpStatus.BAD_REQUEST.value(), statusOf(ErrorCode.CANNOT_BLOCK_SELF)); + } + + // 屏蔽对外统一经 FORBIDDEN(2002) → 403 + @Test + void blockedSend_externallyUsesForbidden_shouldReturnHttp403() { + assertEquals(HttpStatus.FORBIDDEN.value(), statusOf(ErrorCode.FORBIDDEN)); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingControllerTest.java new file mode 100644 index 0000000..381047b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingControllerTest.java @@ -0,0 +1,327 @@ +package com.mydreamjob.competition; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.CandidateDimensionScore; +import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 同岗位竞争力排名控制器集成测试(V32,真实 Service + H2)。 + * + *

覆盖权限矩阵、cohort(WITHDRAWN/REJECTED 排除)、排序、百分位、Top5 脱敏、 + * 无分候选人为 null、岗位状态与参数校验。身份仅取 JWT。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class CompetitionRankingControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private JobRepository jobRepository; + @Autowired private ApplicationRepository applicationRepository; + @Autowired private CandidateDimensionScoreRepository scoreRepository; + + @MockBean private RedisTemplate redisTemplate; + + private static final AtomicLong PHONE = new AtomicLong(900); + + private Long me; // 已投 job1 且有分 + private Long a; // 90 + private Long b; // 80 + private Long c; // 无分 + private Long w; // WITHDRAWN(排除) + private Long r; // REJECTED(排除) + private Long noScoreStudent; // job2 无分本人 + private Long d; // 95 + private Long e; // 80 + private Long companyUserId; + private Long adminUserId; + private Long job1; // PUBLISHED + private Long job2; // PUBLISHED + private Long draftJob; + private Long closedJob; + private String period; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + period = String.format("%04d-%02d", LocalDate.now().getYear(), LocalDate.now().getMonthValue()); + + me = createUser("me"); + a = createUser("A"); + b = createUser("B"); + c = createUser("C"); + w = createUser("W"); + r = createUser("R"); + noScoreStudent = createUser("NSA"); + d = createUser("D"); + e = createUser("E"); + companyUserId = createUser("CC"); + adminUserId = createUser("ADM"); + + // 角色覆盖:所有求职者为 STUDENT,companyUser=COMPANY,admin=ADMIN + setRole(me, User.Role.STUDENT); + setRole(a, User.Role.STUDENT); + setRole(b, User.Role.STUDENT); + setRole(c, User.Role.STUDENT); + setRole(w, User.Role.STUDENT); + setRole(r, User.Role.STUDENT); + setRole(noScoreStudent, User.Role.STUDENT); + setRole(d, User.Role.STUDENT); + setRole(e, User.Role.STUDENT); + + Long companyId = companyRepository.save( + Company.builder().ownerUserId(companyUserId).name("竞争力企业").build()).getId(); + + job1 = newJob(companyId, "后端", "PUBLISHED"); + job2 = newJob(companyId, "前端", "PUBLISHED"); + draftJob = newJob(companyId, "草稿岗", "DRAFT"); + closedJob = newJob(companyId, "关闭岗", "CLOSED"); + + // job1 cohort:me/A/B 有分,C 无分;W(排除)、R(排除) + apply(job1, me, "SUBMITTED"); + apply(job1, a, "SUBMITTED"); + apply(job1, b, "VIEWED"); + apply(job1, c, "SUBMITTED"); + apply(job1, w, "WITHDRAWN"); + apply(job1, r, "REJECTED"); + score(me, new BigDecimal("85.00")); + score(a, new BigDecimal("90.00")); + score(b, new BigDecimal("80.00")); + + // job2:本人无分,D/E 有分 + apply(job2, noScoreStudent, "SUBMITTED"); + apply(job2, d, "SUBMITTED"); + apply(job2, e, "SUBMITTED"); + score(d, new BigDecimal("95.00")); + score(e, new BigDecimal("80.00")); + } + + @AfterEach + void tearDown() { + scoreRepository.deleteAll(); + applicationRepository.deleteAll(); + jobRepository.deleteAll(); + companyRepository.deleteAll(); + userRepository.deleteAll(); + } + + // ── 辅助 ──────────────────────────────────────────────── + + private Long createUser(String tag) { + User u = userRepository.save(User.builder() + .phone("139" + String.format("%08d", PHONE.incrementAndGet())) + .nickname(tag).role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)) + .active(true).verified(true).build()); + return u.getId(); + } + + private void setRole(Long uid, User.Role role) { + User u = userRepository.findById(uid).orElseThrow(); + u.setRoles(Set.of(role)); + u.setRole(role); + userRepository.save(u); + } + + private Long newJob(Long companyId, String title, String status) { + return jobRepository.save(Job.builder().companyId(companyId).title(title).description("d") + .city("杭州").employmentType("FULL_TIME").status(status).build()).getId(); + } + + private void apply(Long jobId, Long studentId, String status) { + applicationRepository.save(Application.builder().jobId(jobId).studentUserId(studentId) + .status(status).build()); + } + + private void score(Long sid, BigDecimal overall) { + scoreRepository.save(CandidateDimensionScore.builder().studentUserId(sid) + .dimension("EDUCATION").period(period).score(overall).status("OK") + .weight(new BigDecimal("12.50")).rulesVersion("candidate-scoring-v0.1") + .calculatedAt(LocalDateTime.now()).build()); + } + + private String token(Long userId) { + return "Bearer " + jwtTokenProvider.generateAccessToken(userId, + String.valueOf(userId), Map.of("roles", "STUDENT")); + } + + // ── 权限矩阵 ────────────────────────────────────────────── + + @Test + void unauthenticated_returns401() throws Exception { + mockMvc.perform(get("/jobs/{id}/competition-ranking", job1)) + .andExpect(status().isUnauthorized()); + } + + @Test + void company_returns403_2002() throws Exception { + mockMvc.perform(get("/jobs/{id}/competition-ranking", job1) + .header("Authorization", tokenWithRole(companyUserId, User.Role.COMPANY))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + @Test + void admin_returns403_2002() throws Exception { + mockMvc.perform(get("/jobs/{id}/competition-ranking", job1) + .header("Authorization", tokenWithRole(adminUserId, User.Role.ADMIN))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + @Test + void studentNotApplied_returns403_2002() throws Exception { + // c 已投 job1,用未投 job1 的 D 学生验证 + mockMvc.perform(get("/jobs/{id}/competition-ranking", job1) + .header("Authorization", token(d))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + // 唯一投递为 WITHDRAWN / REJECTED → 已退出候选人池 → 403/2002 + @Test + void withdrawnStudent_returns403_2002() throws Exception { + mockMvc.perform(get("/jobs/{id}/competition-ranking", job1) + .header("Authorization", token(w))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + @Test + void rejectedStudent_returns403_2002() throws Exception { + mockMvc.perform(get("/jobs/{id}/competition-ranking", job1) + .header("Authorization", token(r))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + @Test + void jobIdNonPositive_returns400_1001() throws Exception { + mockMvc.perform(get("/jobs/{id}/competition-ranking", 0) + .header("Authorization", token(me))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1001)); + } + + @Test + void jobNotExists_returns404_7001() throws Exception { + mockMvc.perform(get("/jobs/{id}/competition-ranking", 999999L) + .header("Authorization", token(me))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(7001)); + } + + @Test + void draftJob_returns404_7001() throws Exception { + mockMvc.perform(get("/jobs/{id}/competition-ranking", draftJob) + .header("Authorization", token(me))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(7001)); + } + + @Test + void closedJob_returns404_7001() throws Exception { + mockMvc.perform(get("/jobs/{id}/competition-ranking", closedJob) + .header("Authorization", token(me))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(7001)); + } + + // ── 排名 / cohort / 百分位 / 脱敏 ────────────────────────── + + @Test + void appliedStudent_returns200_rank_andPercentile_andDesensitizedTop5() throws Exception { + mockMvc.perform(get("/jobs/{id}/competition-ranking", job1) + .header("Authorization", token(me))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.jobId").value(job1)) + // cohort = me,A,B,C(WITHDRAWN/REJECTED 被排除),C 计入 cohortSize + .andExpect(jsonPath("$.data.cohortSize").value(4)) + .andExpect(jsonPath("$.data.rank").value(2)) + .andExpect(jsonPath("$.data.overallScore").value(85.00)) + .andExpect(jsonPath("$.data.percentile").value(50.00)) // lower=1/denom=2 + .andExpect(jsonPath("$.data.status").value("OK")) + .andExpect(jsonPath("$.data.asOfPeriod").value(period)) + .andExpect(jsonPath("$.data.mySnapshotPeriod").value(period)) + .andExpect(jsonPath("$.data.topCandidates.length()").value(4)) + .andExpect(jsonPath("$.data.topCandidates[0].rank").value(1)) + .andExpect(jsonPath("$.data.topCandidates[0].overallScore").value(90.00)) + // Top5 脱敏:不返回任何身份字段 + .andExpect(jsonPath("$.data.topCandidates[0].studentUserId").doesNotExist()) + .andExpect(jsonPath("$.data.topCandidates[0].candidateId").doesNotExist()) + .andExpect(jsonPath("$.data.topCandidates[0].name").doesNotExist()) + .andExpect(jsonPath("$.data.topCandidates[0].school").doesNotExist()) + .andExpect(jsonPath("$.data.topCandidates[0].phone").doesNotExist()) + .andExpect(jsonPath("$.data.topCandidates[0].email").doesNotExist()) + .andExpect(jsonPath("$.data.topCandidates[0].applicationId").doesNotExist()) + // 响应整体也不出现 candidateId/studentUserId 顶层字段 + .andExpect(jsonPath("$.data.candidateId").doesNotExist()) + .andExpect(jsonPath("$.data.studentUserId").doesNotExist()); + } + + @Test + void withdrawnRejected_notInCohort() throws Exception { + // w/r 虽被投递但状态为 WITHDRAWN/REJECTED,cohort 仍为 4(me,A,B,C) + mockMvc.perform(get("/jobs/{id}/competition-ranking", job1) + .header("Authorization", token(me))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.cohortSize").value(4)) + .andExpect(jsonPath("$.data.topCandidates.length()").value(4)); + } + + @Test + void currentStudentNoScore_rankScorePercentileNull() throws Exception { + mockMvc.perform(get("/jobs/{id}/competition-ranking", job2) + .header("Authorization", token(noScoreStudent))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.cohortSize").value(3)) + .andExpect(jsonPath("$.data.rank").value(org.hamcrest.Matchers.nullValue())) + .andExpect(jsonPath("$.data.overallScore").value(org.hamcrest.Matchers.nullValue())) + .andExpect(jsonPath("$.data.percentile").value(org.hamcrest.Matchers.nullValue())) + .andExpect(jsonPath("$.data.mySnapshotPeriod").value(org.hamcrest.Matchers.nullValue())); + } + + private String tokenWithRole(Long userId, User.Role role) { + return "Bearer " + jwtTokenProvider.generateAccessToken(userId, + String.valueOf(userId), Map.of("roles", role.name())); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java new file mode 100644 index 0000000..4d49b91 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java @@ -0,0 +1,344 @@ +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 同岗位竞争力排名服务单测(V32)。 + * + *

用 Mock 隔离 repo/Calculator 以精确断言排序、cohort、百分位、Top5 脱敏与周期选择; + * 排序纯 Java(有分优先、overallScore DESC、createdAt ASC、studentUserId ASC、无分最后)。

+ */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CompetitionRankingServiceTest { + + private static final Long ME = 100L; + private static final Long JOB = 9L; + + @Mock private UserRepository userRepository; + @Mock private JobService jobService; + @Mock private ApplicationRepository applicationRepository; + @Mock private CandidateDimensionScoreRepository scoreRepository; + @Mock private CandidateScoringCalculator calculator; + + @InjectMocks private CompetitionRankingService service; + + private String currentPeriod; + private Map overallByStudent; + + @BeforeEach + void setUp() { + currentPeriod = String.format("%04d-%02d", LocalDate.now().getYear(), LocalDate.now().getMonthValue()); + overallByStudent = Map.of(); + mockStudent(ME); + // 默认按传入快照首条 studentUserId 返回预设综合分 + when(calculator.overallScoreFromSnapshots(anyList())).thenAnswer(inv -> { + List snaps = inv.getArgument(0); + if (snaps == null || snaps.isEmpty()) return null; + Number sid = ((CandidateDimensionScore) snaps.get(0)).getStudentUserId(); + return overallByStudent.get(sid.longValue()); + }); + } + + // ── 辅助 ──────────────────────────────────────────────── + + private void mockStudent(Long uid) { + User u = User.builder().id(uid).phone("1").role(User.Role.STUDENT).build(); + u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + when(userRepository.findById(uid)).thenReturn(Optional.of(u)); + } + + private void mockRole(Long uid, User.Role role) { + User u = User.builder().id(uid).phone("1").role(role).build(); + u.setRoles(new HashSet<>(Set.of(role))); + when(userRepository.findById(uid)).thenReturn(Optional.of(u)); + } + + private void mockPublishedJob() { + Job j = Job.builder().id(JOB).status("PUBLISHED").build(); + when(jobService.getJob(JOB)).thenReturn(j); + } + + private CompetitionRankingResponse call() { + return service.getRanking(ME, JOB); + } + + // ── 权限与参数校验 ───────────────────────────────────────── + + @Test + void jobIdZeroOrNull_returnsBadRequest() { + BusinessException ex = assertThrows(BusinessException.class, () -> service.getRanking(ME, 0L)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); + } + + @Test + void nonStudent_returnsForbidden() { + mockRole(ME, User.Role.COMPANY); + BusinessException ex = assertThrows(BusinessException.class, () -> call()); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); + } + + @Test + void jobNotFound_returnsJobNotFound() { + when(jobService.getJob(JOB)).thenThrow(new BusinessException(ErrorCode.JOB_NOT_FOUND)); + BusinessException ex = assertThrows(BusinessException.class, () -> call()); + assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCode()); + } + + @Test + void nonPublishedJob_returnsJobNotFound() { + Job draft = Job.builder().id(JOB).status("DRAFT").build(); + when(jobService.getJob(JOB)).thenReturn(draft); + BusinessException ex = assertThrows(BusinessException.class, () -> call()); + assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCode()); + } + + @Test + void notAppliedStudent_returnsForbidden() { + mockPublishedJob(); + when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(false); + BusinessException ex = assertThrows(BusinessException.class, () -> call()); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); + } + + // 唯一投递为 WITHDRAWN / REJECTED → 已退出候选人池 → 403(与 cohort 同口径) + @Test + void withdrawnOnly_returnsForbidden() { + mockPublishedJob(); + when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(false); + BusinessException ex = assertThrows(BusinessException.class, () -> call()); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); + } + + @Test + void rejectedOnly_returnsForbidden() { + mockPublishedJob(); + when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(false); + BusinessException ex = assertThrows(BusinessException.class, () -> call()); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); + } + + // 有效状态投递(SUBMITTED/VIEWED/INTERVIEW/HIRED)→ 200 + @Test + void effectiveStatus_returns200() { + mockPublishedJob(); + when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); + when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) + .thenReturn(List.of(app(ME, 1, "2026-01-01T10:00:00"))); + overallByStudent = Map.of(); + when(scoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of()); + CompetitionRankingResponse resp = call(); + assertEquals(JOB, resp.getJobId()); + assertEquals(1, resp.getCohortSize()); + } + + // ── 排序 / cohort / 百分位 / Top5 ────────────────────────── + + @Test + void ranking_pipeline_checked() { + mockPublishedJob(); + when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); + // me 中位,A 更高,B 更低,C 无分 + Application meApp = app(ME, 1, "2026-01-05T10:00:00"); + Application a = app(501L, 2, "2026-01-01T10:00:00"); + Application b = app(502L, 3, "2026-01-02T10:00:00"); + Application c = app(503L, 4, "2026-01-03T10:00:00"); // 无分 + when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) + .thenReturn(List.of(meApp, a, b, c)); + overallByStudent = Map.of(ME, new BigDecimal("85.00"), + a.getStudentUserId(), new BigDecimal("90.00"), + b.getStudentUserId(), new BigDecimal("80.00")); + // c 无评分快照 + when(scoreRepository.findUpToPeriod(anyCollection(), anyString())) + .thenReturn(List.of(dim(ME, "EDUCATION", "2026-07"), + dim(a.getStudentUserId(), "EDUCATION", "2026-07"), + dim(b.getStudentUserId(), "EDUCATION", "2026-07"))); + + CompetitionRankingResponse resp = call(); + + assertEquals(JOB, resp.getJobId()); + assertEquals(4, resp.getCohortSize()); // 无分候选人计入 cohortSize + assertEquals(2, resp.getRank()); // me 排第 2 + assertEquals(new BigDecimal("85.00"), resp.getOverallScore()); + assertEquals(new BigDecimal("50.00"), resp.getPercentile()); // lower=1, denom=2, 1/2*100 + // Top5 匿名:rank1 A=90, rank2 me=85, rank3 B=80, rank4 C=null + assertEquals(4, resp.getTopCandidates().size()); + assertEquals(1, resp.getTopCandidates().get(0).getRank()); + assertEquals(new BigDecimal("90.00"), resp.getTopCandidates().get(0).getOverallScore()); + assertEquals(4, resp.getTopCandidates().get(3).getRank()); + assertNull(resp.getTopCandidates().get(3).getOverallScore()); + assertEquals(currentPeriod, resp.getAsOfPeriod()); + assertEquals("2026-07", resp.getMySnapshotPeriod()); + // 无身份字段泄露:结构上仅含 rank + overallScore + resp.getTopCandidates().forEach(t -> assertNotNull(t.getRank())); + assertEquals(1, resp.getDimensions().size()); + } + + @Test + void scoredFirst_noScoreLast_secondarySortStable() { + mockPublishedJob(); + when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); + // 同分 77.00、不同 createdAt + Application x = app(600L, 1, "2026-02-01T10:00:00"); // 更早 + Application y = app(601L, 2, "2026-02-02T10:00:00"); // 更晚 + // 无分学生 + Application n1 = app(700L, 3, "2026-01-01T10:00:00"); + Application n2 = app(701L, 4, "2026-01-01T10:00:00"); + when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) + .thenReturn(List.of(n1, x, y, n2, app(ME, 5, "2026-03-01T10:00:00"))); + overallByStudent = Map.of(x.getStudentUserId(), new BigDecimal("77.00"), + y.getStudentUserId(), new BigDecimal("77.00"), + ME, new BigDecimal("77.00")); + when(scoreRepository.findUpToPeriod(anyCollection(), anyString())) + .thenReturn(List.of(dim(x.getStudentUserId(), "EDUCATION", "2026-07"), + dim(y.getStudentUserId(), "EDUCATION", "2026-07"), + dim(ME, "EDUCATION", "2026-07"))); + + CompetitionRankingResponse resp = call(); + // 有分在前、无分最后;同分按 createdAt 升序(x 更早因此 x 在 y 前);本人有分排第 3 + assertEquals(3, resp.getRank()); + assertEquals(new BigDecimal("77.00"), resp.getTopCandidates().get(0).getOverallScore()); + assertEquals(new BigDecimal("77.00"), resp.getTopCandidates().get(1).getOverallScore()); + // 无分候选人的 overallScore 为 null 且排在末尾 + assertNull(resp.getTopCandidates().get(3).getOverallScore()); + assertEquals(5, resp.getCohortSize()); + } + + @Test + void ownScoreSameAsOther_tieBrokenByCreatedAtThenStudentUserId() { + mockPublishedJob(); + when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); + // me 与 x 同分但 x 投递更早 → me 排后 + Application x = app(900L, 1, "2026-04-01T10:00:00"); + Application mm = app(ME, 2, "2026-04-02T10:00:00"); + when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) + .thenReturn(List.of(x, mm)); + overallByStudent = Map.of(x.getStudentUserId(), new BigDecimal("60.00"), + ME, new BigDecimal("60.00")); + when(scoreRepository.findUpToPeriod(anyCollection(), anyString())) + .thenReturn(List.of(dim(x.getStudentUserId(), "EDUCATION", "2026-07"), + dim(ME, "EDUCATION", "2026-07"))); + + CompetitionRankingResponse resp = call(); + // 有分 2 人 → percentile null(scored<2 分位为 null 的规则由百分位测试覆盖) + assertEquals(2, resp.getRank()); // x first, me second + } + + @Test + void currentStudentNoScore_rankScorePercentileNull() { + mockPublishedJob(); + when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); + Application a = app(501L, 1, "2026-01-01T10:00:00"); + Application mm = app(ME, 2, "2026-01-02T10:00:00"); + when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) + .thenReturn(List.of(a, mm)); + overallByStudent = Map.of(a.getStudentUserId(), new BigDecimal("90.00")); // me 无分 + when(scoreRepository.findUpToPeriod(anyCollection(), anyString())) + .thenReturn(List.of(dim(a.getStudentUserId(), "EDUCATION", "2026-07"))); + + CompetitionRankingResponse resp = call(); + assertEquals(2, resp.getCohortSize()); + assertNull(resp.getRank()); + assertNull(resp.getOverallScore()); + assertNull(resp.getPercentile()); + assertNull(resp.getMySnapshotPeriod()); + assertEquals("INSUFFICIENT_DATA", resp.getStatus()); + } + + @Test + void percentile_scoredLessThan2_returnsNull() { + mockPublishedJob(); + when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); + Application a = app(501L, 1, "2026-01-01T10:00:00"); + Application mm = app(ME, 2, "2026-01-02T10:00:00"); + when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) + .thenReturn(List.of(a, mm)); + overallByStudent = Map.of(ME, new BigDecimal("85.00")); // 仅 1 个有分 + when(scoreRepository.findUpToPeriod(anyCollection(), anyString())) + .thenReturn(List.of(dim(ME, "EDUCATION", "2026-07"))); + + CompetitionRankingResponse resp = call(); + assertNull(resp.getPercentile()); + } + + @Test + void latestPeriodSelected_multipleMonthlySnapshots() { + mockPublishedJob(); + when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); + Application mm = app(ME, 1, "2026-01-01T10:00:00"); + when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) + .thenReturn(List.of(mm)); + overallByStudent = Map.of(ME, new BigDecimal("70.00")); + // 仅 discord 学生有多期;用 ME 返回两期,latest=latest + when(scoreRepository.findUpToPeriod(anyCollection(), anyString())) + .thenReturn(List.of(dim(ME, "EDUCATION", "2026-06"), dim(ME, "EDUCATION", "2026-07"))); + + CompetitionRankingResponse resp = call(); + assertEquals("2026-07", resp.getMySnapshotPeriod()); + assertEquals(1, resp.getDimensions().size()); // 仅最新周期 1 条 + } + + @Test + void topCandidates_cappedAt5() { + mockPublishedJob(); + when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); + var apps = new java.util.ArrayList(); + apps.add(app(ME, 1, "2026-01-01T10:00:00")); + for (long i = 2; i <= 8; i++) { + apps.add(app(1000L + i, (int) i, "2026-01-0" + (i % 9) + "T10:00:00")); + } + when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) + .thenReturn(apps); + overallByStudent = Map.of(); + when(scoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of()); + // 全部无分场景,仅验证 Top5 上限 + CompetitionRankingResponse resp = call(); + assertEquals(5, resp.getTopCandidates().size()); + } + + // ── 载体构造 ────────────────────────────────────────────── + + private Application app(Long sid, int idx, String created) { + return Application.builder().jobId(JOB).studentUserId(sid) + .status(idx % 3 == 0 ? "VIEWED" : "SUBMITTED") + .createdAt(LocalDateTime.parse(created)).build(); + } + + private CandidateDimensionScore dim(Long sid, String dimension, String period) { + return CandidateDimensionScore.builder().studentUserId(sid).dimension(dimension).period(period) + .weight(new BigDecimal("12.50")).rulesVersion("candidate-scoring-v0.1") + .status("OK").build(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthIntegrationTest.java index a019a8b..a1aa0f4 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/controller/AuthIntegrationTest.java @@ -4,7 +4,12 @@ import com.mydreamjob.auth.AuthTokenRepository; import com.mydreamjob.auth.JwtTokenProvider; import com.mydreamjob.auth.SmsVerificationCode; import com.mydreamjob.auth.SmsVerificationCodeRepository; +import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.deletion.AccountDeletion; +import com.mydreamjob.deletion.AccountDeletionPhoneHasher; +import com.mydreamjob.deletion.AccountDeletionRepository; +import com.mydreamjob.deletion.DeletionStatus; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -17,8 +22,10 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -65,6 +72,12 @@ class AuthIntegrationTest { @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired + private AccountDeletionRepository deletionRepository; + + @Autowired + private AccountDeletionPhoneHasher phoneHasher; + private static final String PHONE = "13800000001"; private static final String PURPOSE = "login"; private static final String WRONG_PURPOSE = "password_reset"; @@ -80,6 +93,7 @@ class AuthIntegrationTest { when(redisTemplate.opsForValue()).thenReturn(ops); codeRepository.deleteAll(); authTokenRepository.deleteAll(); + deletionRepository.deleteAll(); userRepository.deleteAll(); } @@ -239,6 +253,50 @@ class AuthIntegrationTest { .andExpect(jsonPath("$.code").value(401)); } + // ── 已注销手机号禁止登录 ─────────────────────────────────── + + @Test + void login_withDeletedPhone_returns409_9303_andNoNewUser() throws Exception { + // 1. 先发验证码(mockLogin 先校验验证码再校验手机号墓碑) + mockMvc.perform(post("/auth/sms/send") + .param("phone", PHONE) + .param("purpose", PURPOSE)) + .andExpect(status().isOk()); + String code = getLatestUnusedCode(PHONE, PURPOSE); + assertThat(code).isNotNull(); + + // 2. 构造已注销用户 + 原手机号墓碑(HMAC 摘要) + String phoneHash = phoneHasher.hash(PHONE); + User deleted = userRepository.save(User.builder() + .phone("deleted_99998") + .roles(Set.of(User.Role.STUDENT)) + .role(User.Role.STUDENT) + .active(false) + .build()); + deletionRepository.save(AccountDeletion.builder() + .userId(deleted.getId()) + .status(DeletionStatus.COMPLETED) + .deletedPhoneHash(phoneHash) + .completedAt(LocalDateTime.now()) + .version(0L) + .build()); + + long before = userRepository.count(); + + // 3. 用原手机号登录 → HTTP 409 / code=9303 + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"" + PHONE + "\"," + + "\"code\":\"" + code + "\"," + + "\"purpose\":\"" + PURPOSE + "\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9303)); + + // 4. 不能自动创建新用户 + assertThat(userRepository.count()).isEqualTo(before); + assertThat(userRepository.findByPhone(PHONE)).isEmpty(); + } + // ── 辅助方法 ─────────────────────────────────────────────── private String getLatestUnusedCode(String phone, String purpose) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/DataExportServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/DataExportServiceTest.java new file mode 100644 index 0000000..5ce981f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/DataExportServiceTest.java @@ -0,0 +1,318 @@ +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 com.mydreamjob.deletion.AccountDeletionService; +import com.mydreamjob.deletion.DeletionStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class DataExportServiceTest { + + private PersonalDataExportTaskRepository taskRepository; + private PersonalDataExportWorker worker; + private PersonalDataExportStorageService storageService; + private AccountDeletionService deletionService; + private AuditService auditService; + private DataExportService service; + + private static Long userA = 100L; + private static Long userB = 200L; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + taskRepository = mock(PersonalDataExportTaskRepository.class); + worker = mock(PersonalDataExportWorker.class); + storageService = mock(PersonalDataExportStorageService.class); + deletionService = mock(AccountDeletionService.class); + auditService = mock(AuditService.class); + service = new DataExportService(taskRepository, worker, storageService, deletionService, auditService); + when(deletionService.effectiveStatus(anyLong())).thenReturn(DeletionStatus.NONE); + } + + @Test + void create_pending_ok() { + when(taskRepository.save(any(PersonalDataExportTask.class))) + .thenAnswer(inv -> { + PersonalDataExportTask t = inv.getArgument(0); + t.setId(1L); + return t; + }); + DataExportTaskResponse resp = service.create(userA, new CreateDataExportRequest()); + assertThat(resp.getStatus()).isEqualTo(PersonalDataExportTask.STATUS_PENDING); + assertThat(resp.getTaskId()).isEqualTo(1L); + verify(worker).execute(1L); + } + + @Test + void create_invalidFormat_throws1001() { + CreateDataExportRequest req = new CreateDataExportRequest(); + req.setFormat("PDF"); + assertThatThrownBy(() -> service.create(userA, req)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.BAD_REQUEST.getCode()); + } + + @Test + void create_jsonAndBlankFormat_throw1001() { + for (String bad : new String[]{"JSON", "", " "}) { + CreateDataExportRequest req = new CreateDataExportRequest(); + req.setFormat(bad); + assertThatThrownBy(() -> service.create(userA, req)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.BAD_REQUEST.getCode()); + } + } + + @Test + void create_auditsRequested_once() { + when(taskRepository.save(any(PersonalDataExportTask.class))) + .thenAnswer(inv -> { + PersonalDataExportTask t = inv.getArgument(0); + t.setId(1L); + return t; + }); + service.create(userA, new CreateDataExportRequest()); + verify(auditService).log(eq(userA), eq("DATA_EXPORT_REQUESTED"), + eq("PERSONAL_DATA_EXPORT"), eq(1L), anyString(), isNull(), isNull(), isNull()); + } + + @Test + void create_auditDetails_doNotLeakSensitiveData() { + when(taskRepository.save(any(PersonalDataExportTask.class))) + .thenAnswer(inv -> { + PersonalDataExportTask t = inv.getArgument(0); + t.setId(1L); + return t; + }); + service.create(userA, new CreateDataExportRequest()); + ArgumentCaptor cap = ArgumentCaptor.forClass(String.class); + verify(auditService).log(eq(userA), eq("DATA_EXPORT_REQUESTED"), + eq("PERSONAL_DATA_EXPORT"), eq(1L), cap.capture(), isNull(), isNull(), isNull()); + String d = cap.getValue().toLowerCase(); + assertThat(d).contains("taskid").contains("userid").contains("format").contains("status"); + assertThat(d).doesNotContain("phone").doesNotContain("email").doesNotContain("password") + .doesNotContain("token").doesNotContain("storagekey").doesNotContain("jwt") + .doesNotContain("secret"); + } + + @Test + void create_sameIdempotencyKey_doesNotAuditRequestedAgain() { + CreateDataExportRequest req = new CreateDataExportRequest(); + req.setFormat("ZIP"); + req.setIdempotencyKey("k1"); + PersonalDataExportTask existing = task(9L, userA, PersonalDataExportTask.STATUS_PENDING, null); + when(taskRepository.findFirstByUserIdAndIdempotencyKeyOrderByCreatedAtDesc(userA, "k1")) + .thenReturn(Optional.of(existing)); + service.create(userA, req); + verify(auditService, never()).log(eq(userA), eq("DATA_EXPORT_REQUESTED"), + eq("PERSONAL_DATA_EXPORT"), anyLong(), anyString(), any(), any(), any()); + } + + @Test + void create_duplicateUnfinished_throws9402() { + when(taskRepository.existsByUserIdAndStatusIn(anyLong(), any())).thenReturn(true); + assertThatThrownBy(() -> service.create(userA, new CreateDataExportRequest())) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.DATA_EXPORT_DUPLICATE.getCode()); + } + + @Test + void create_sameIdempotencyKey_returnsExisting() { + CreateDataExportRequest req = new CreateDataExportRequest(); + req.setFormat("ZIP"); + req.setIdempotencyKey("k1"); + PersonalDataExportTask existing = task(9L, userA, PersonalDataExportTask.STATUS_PENDING, null); + when(taskRepository.findFirstByUserIdAndIdempotencyKeyOrderByCreatedAtDesc(userA, "k1")) + .thenReturn(Optional.of(existing)); + DataExportTaskResponse resp = service.create(userA, req); + assertThat(resp.getTaskId()).isEqualTo(9L); + verify(worker, never()).execute(anyLong()); + } + + @Test + void create_completedAndNotExpired_returnsExisting() { + PersonalDataExportTask done = task(5L, userA, PersonalDataExportTask.STATUS_COMPLETED, + LocalDateTime.now().plusHours(10)); + done.setStorageKey("100/5.zip"); + when(taskRepository.findFirstByUserIdAndStatusOrderByCreatedAtDesc(userA, + PersonalDataExportTask.STATUS_COMPLETED)).thenReturn(Optional.of(done)); + DataExportTaskResponse resp = service.create(userA, new CreateDataExportRequest()); + assertThat(resp.getTaskId()).isEqualTo(5L); + assertThat(resp.isDownloadAvailable()).isTrue(); + } + + @Test + void create_deletionPending_throws9302() { + when(deletionService.effectiveStatus(userA)).thenReturn(DeletionStatus.PENDING); + assertThatThrownBy(() -> service.create(userA, new CreateDataExportRequest())) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.ACCOUNT_DELETION_PENDING.getCode()); + } + + @Test + void create_deletionCompleted_throws9303() { + when(deletionService.effectiveStatus(userA)).thenReturn(DeletionStatus.COMPLETED); + assertThatThrownBy(() -> service.create(userA, new CreateDataExportRequest())) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.ACCOUNT_DELETED.getCode()); + } + + @Test + void list_outOfRange_throws() { + assertThatThrownBy(() -> service.list(userA, -1, 20, null)) + .isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.list(userA, 0, 0, null)) + .isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.list(userA, 0, 101, null)) + .isInstanceOf(BusinessException.class); + } + + @Test + void list_ok() { + Page page = new PageImpl<>(List.of( + task(1L, userA, PersonalDataExportTask.STATUS_COMPLETED, LocalDateTime.now().plusHours(1)))); + when(taskRepository.findByUserIdOrderByCreatedAtDesc(anyLong(), any(Pageable.class))).thenReturn(page); + PageResponse resp = service.list(userA, 0, 20, null); + assertThat(resp.getItems()).hasSize(1); + assertThat(resp.getTotal()).isEqualTo(1); + } + + @Test + void get_otherUser_throws403() { + when(taskRepository.findById(3L)).thenReturn(Optional.of(task(3L, userB, "PENDING", null))); + assertThatThrownBy(() -> service.get(userA, 3L)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.FORBIDDEN.getCode()); + } + + @Test + void get_notFound_throws9401() { + when(taskRepository.findById(404L)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.get(userA, 404L)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.DATA_EXPORT_TASK_NOT_FOUND.getCode()); + } + + @Test + void download_pending_throws9403() { + when(taskRepository.findById(1L)).thenReturn(Optional.of(task(1L, userA, "PENDING", null))); + assertThatThrownBy(() -> service.download(userA, 1L)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.DATA_EXPORT_NOT_READY.getCode()); + } + + @Test + void download_failed_throws9405() { + when(taskRepository.findById(1L)).thenReturn(Optional.of(task(1L, userA, "FAILED", null))); + assertThatThrownBy(() -> service.download(userA, 1L)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.DATA_EXPORT_FAILED.getCode()); + } + + @Test + void download_expired_throws9404() { + PersonalDataExportTask t = task(1L, userA, PersonalDataExportTask.STATUS_COMPLETED, + LocalDateTime.now().minusHours(1)); + t.setStorageKey("100/1.zip"); + when(taskRepository.findById(1L)).thenReturn(Optional.of(t)); + assertThatThrownBy(() -> service.download(userA, 1L)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.DATA_EXPORT_EXPIRED.getCode()); + } + + @Test + void download_fileMissing_throws9406() { + PersonalDataExportTask t = task(1L, userA, PersonalDataExportTask.STATUS_COMPLETED, + LocalDateTime.now().plusHours(1)); + t.setStorageKey("100/1.zip"); + when(taskRepository.findById(1L)).thenReturn(Optional.of(t)); + when(storageService.read("100/1.zip")).thenThrow( + new BusinessException(ErrorCode.DATA_EXPORT_FILE_NOT_FOUND)); + assertThatThrownBy(() -> service.download(userA, 1L)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.DATA_EXPORT_FILE_NOT_FOUND.getCode()); + } + + @Test + void download_success_recordsDownloadedAt_andAuditsOnce() { + PersonalDataExportTask t = task(1L, userA, PersonalDataExportTask.STATUS_COMPLETED, + LocalDateTime.now().plusHours(1)); + t.setStorageKey("100/1.zip"); + t.setSizeBytes(4L); + when(taskRepository.findById(1L)).thenReturn(Optional.of(t)); + when(storageService.read("100/1.zip")).thenReturn(new byte[]{0x50, 0x4B, 0x03, 0x04}); + DataExportService.DownloadFile df = service.download(userA, 1L); + assertThat(df.content()).hasSize(4); + assertThat(df.filename()).endsWith(".zip"); + assertThat(t.getDownloadedAt()).isNotNull(); + verify(taskRepository).save(t); + verify(auditService).log(eq(userA), eq("DATA_EXPORT_DOWNLOADED"), + eq("PERSONAL_DATA_EXPORT"), eq(1L), anyString(), isNull(), isNull(), isNull()); + } + + @Test + void download_repeated_auditsOnlyOnce() { + PersonalDataExportTask t = task(1L, userA, PersonalDataExportTask.STATUS_COMPLETED, + LocalDateTime.now().plusHours(1)); + t.setStorageKey("100/1.zip"); + t.setSizeBytes(4L); + when(taskRepository.findById(1L)).thenReturn(Optional.of(t)); + when(storageService.read("100/1.zip")).thenReturn(new byte[]{0x50, 0x4B, 0x03, 0x04}); + service.download(userA, 1L); + service.download(userA, 1L); + // 首次下载后 downloadedAt 已设置,第二次不再记录 DOWNLOADED,也不再保存任务 + verify(auditService, times(1)).log(eq(userA), eq("DATA_EXPORT_DOWNLOADED"), + eq("PERSONAL_DATA_EXPORT"), eq(1L), anyString(), isNull(), isNull(), isNull()); + verify(taskRepository, times(1)).save(t); + } + + private PersonalDataExportTask task(Long id, Long userId, String status, LocalDateTime expiresAt) { + return PersonalDataExportTask.builder() + .id(id).userId(userId) + .status(status) + .format(PersonalDataExportTask.FORMAT_ZIP) + .requestedAt(LocalDateTime.now()) + .expiresAt(expiresAt) + .version(0L) + .build(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportBuilderTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportBuilderTest.java new file mode 100644 index 0000000..1d7f445 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportBuilderTest.java @@ -0,0 +1,88 @@ +package com.mydreamjob.dataexport; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static org.assertj.core.api.Assertions.assertThat; + +class PersonalDataExportBuilderTest { + + private final ObjectMapper om = new ObjectMapper(); + private final PersonalDataExportBuilder builder = new PersonalDataExportBuilder(om); + + @Test + void build_producesReadableZip_withManifestAndSections() throws Exception { + PersonalDataCollector.CollectResult collect = new PersonalDataCollector.CollectResult(); + Map profile = new LinkedHashMap<>(); + profile.put("userId", 100L); + profile.put("nickname", "demo"); + collect.getSections().put("profile", profile); + collect.getSections().put("favorites", new java.util.ArrayList<>()); + collect.getBinaries().put("resumes/1-resume.pdf", new byte[]{1, 2, 3}); + + PersonalDataExportBuilder.Result result = builder.build(100L, collect); + + assertThat(result.content()).isNotEmpty(); + assertThat(result.checksumSha256()).hasSize(64); + + // 校验 ZIP 内容 + Map entries = readZip(result.content()); + assertThat(entries).containsKey("personal-data/manifest.json"); + assertThat(entries).containsKey("personal-data/profile.json"); + assertThat(entries).containsKey("personal-data/favorites.json"); + assertThat(entries).containsKey("personal-data/resumes/1-resume.pdf"); + + JsonNode manifest = om.readTree(new String(entries.get("personal-data/manifest.json"), StandardCharsets.UTF_8)); + assertThat(manifest.path("format").asText()).isEqualTo("ZIP"); + assertThat(manifest.path("schemaVersion").asText()).isEqualTo("1.0"); + assertThat(manifest.path("sections").toString()) + .contains("\"profile\"").contains("\"favorites\""); + + // 校验校验和确实为 ZIP 内容的 SHA-256 + assertThat(result.checksumSha256()) + .isEqualTo(shaHex(result.content())); + } + + @Test + void build_unserializableSection_fallsBackToEmptyArray_notFatal() throws Exception { + PersonalDataCollector.CollectResult collect = new PersonalDataCollector.CollectResult(); + collect.getSections().put("profile", new Object() { + @Override + public String toString() { + return "no json serializer"; + } + }); + + PersonalDataExportBuilder.Result result = builder.build(100L, collect); + assertThat(result.content()).isNotEmpty(); + Map entries = readZip(result.content()); + // 无法序列化的 section 以 "[]" 兜底,不导致整个导出失败 + assertThat(new String(entries.get("personal-data/profile.json"), StandardCharsets.UTF_8)) + .isEqualTo("[]"); + } + + private Map readZip(byte[] content) throws Exception { + Map map = new LinkedHashMap<>(); + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(content), StandardCharsets.UTF_8)) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + byte[] buf = zis.readAllBytes(); + map.put(entry.getName(), buf); + } + } + return map; + } + + private String shaHex(byte[] data) throws Exception { + return java.util.HexFormat.of().formatHex( + java.security.MessageDigest.getInstance("SHA-256").digest(data)); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportControllerTest.java new file mode 100644 index 0000000..89f628d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportControllerTest.java @@ -0,0 +1,326 @@ +package com.mydreamjob.dataexport; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.audit.AuditLog; +import com.mydreamjob.audit.AuditLogRepository; +import com.mydreamjob.dataexport.storage.PersonalDataExportStorageService; +import com.mydreamjob.deletion.AccountDeletion; +import com.mydreamjob.deletion.AccountDeletionRepository; +import com.mydreamjob.deletion.DeletionStatus; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 个人数据导出控制器契约集成测试(H2 integrationdb)。 + * + *

覆盖:未登录 401、STUDENT/COMPANY/ADMIN 均可操作本人任务、非法 format/page/size 400、 + * 下载状态机(PENDING 409/EXPIRED 409/COMPLETED 200)、他人任务 403、注销 PENDING/COMPLETED 账号被阻止。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class PersonalDataExportControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private PersonalDataExportTaskRepository taskRepository; + @Autowired private PersonalDataExportStorageService storageService; + @Autowired private AccountDeletionRepository deletionRepository; + @Autowired private AuditLogRepository auditLogRepository; + + @MockBean private org.springframework.data.redis.core.RedisTemplate redisTemplate; + + // 屏蔽真实异步 worker,避免后台异步完成导出导致与 tearDown 清理并发(乐观锁冲突) + @MockBean private PersonalDataExportWorker worker; + + private static final AtomicLong PHONE = new AtomicLong(932_000_000L); + private static final byte[] ZIP_BYTES = new byte[]{0x50, 0x4B, 0x03, 0x04}; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + org.springframework.data.redis.core.ValueOperations ops = + mock(org.springframework.data.redis.core.ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + } + + @AfterEach + void tearDown() { + taskRepository.deleteAll(); + deletionRepository.deleteAll(); + userRepository.deleteAll(); + auditLogRepository.deleteAll(); + } + + private long createUser(User.Role role) { + User u = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .roles(Set.of(role)) + .role(role) + .active(true) + .build()); + return u.getId(); + } + + private String token(long userId, User.Role role) { + return "Bearer " + jwtTokenProvider.generateAccessToken( + userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", role.name())); + } + + private PersonalDataExportTask pendingTask(Long userId) { + PersonalDataExportTask t = PersonalDataExportTask.builder() + .userId(userId) + .format(PersonalDataExportTask.FORMAT_ZIP) + .build(); + t.setId(null); + return taskRepository.save(t); + } + + private Long completedTaskWithFile(Long userId) { + PersonalDataExportTask t = pendingTask(userId); + String key = storageService.store(userId, t.getId(), ZIP_BYTES); + t.setStatus(PersonalDataExportTask.STATUS_COMPLETED); + t.setCompletedAt(LocalDateTime.now()); + t.setExpiresAt(LocalDateTime.now().plusHours(24)); + t.setStorageKey(key); + t.setSizeBytes((long) ZIP_BYTES.length); + taskRepository.save(t); + return t.getId(); + } + + @Test + void noToken_allEndpoints_401() throws Exception { + mockMvc.perform(post("/profile/me/data-export-tasks")).andExpect(status().isUnauthorized()); + mockMvc.perform(get("/profile/me/data-export-tasks")).andExpect(status().isUnauthorized()); + mockMvc.perform(get("/profile/me/data-export-tasks/1")).andExpect(status().isUnauthorized()); + mockMvc.perform(get("/profile/me/data-export-tasks/1/download")).andExpect(status().isUnauthorized()); + } + + @Test + void create_student_returnsPending_200() throws Exception { + long uid = createUser(User.Role.STUDENT); + mockMvc.perform(post("/profile/me/data-export-tasks") + .header("Authorization", token(uid, User.Role.STUDENT)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"format\":\"ZIP\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.format").value("ZIP")) + .andExpect(jsonPath("$.data.downloadAvailable").value(false)); + } + + @Test + void allRolesCanCreateOwnTask() throws Exception { + for (User.Role role : new User.Role[]{User.Role.STUDENT, User.Role.COMPANY, User.Role.ADMIN}) { + long uid = createUser(role); + mockMvc.perform(post("/profile/me/data-export-tasks") + .header("Authorization", token(uid, role)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"format\":\"ZIP\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PENDING")); + } + } + + @Test + void create_invalidFormat_400_1001() throws Exception { + long uid = createUser(User.Role.STUDENT); + String tk = token(uid, User.Role.STUDENT); + for (String fmt : new String[]{"PDF", "JSON", ""}) { + mockMvc.perform(post("/profile/me/data-export-tasks") + .header("Authorization", tk) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"format\":\"" + fmt + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1001)); + } + } + + @Test + void list_outOfRange_400() throws Exception { + long uid = createUser(User.Role.STUDENT); + String tk = token(uid, User.Role.STUDENT); + mockMvc.perform(get("/profile/me/data-export-tasks").header("Authorization", tk).param("page", "-1")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/profile/me/data-export-tasks").header("Authorization", tk).param("size", "0")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/profile/me/data-export-tasks").header("Authorization", tk).param("size", "101")) + .andExpect(status().isBadRequest()); + } + + @Test + void download_pending_409_9403() throws Exception { + long uid = createUser(User.Role.STUDENT); + Long taskId = pendingTask(uid).getId(); + mockMvc.perform(get("/profile/me/data-export-tasks/" + taskId + "/download") + .header("Authorization", token(uid, User.Role.STUDENT))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9403)); + } + + @Test + void download_completed_200_attachment() throws Exception { + long uid = createUser(User.Role.STUDENT); + Long taskId = completedTaskWithFile(uid); + mockMvc.perform(get("/profile/me/data-export-tasks/" + taskId + "/download") + .header("Authorization", token(uid, User.Role.STUDENT))) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, org.hamcrest.Matchers.containsString("attachment"))) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, org.hamcrest.Matchers.containsString("application/zip"))); + } + + @Test + void download_expired_409_9404() throws Exception { + long uid = createUser(User.Role.STUDENT); + PersonalDataExportTask t = pendingTask(uid); + String key = storageService.store(uid, t.getId(), ZIP_BYTES); + t.setStatus(PersonalDataExportTask.STATUS_COMPLETED); + t.setCompletedAt(LocalDateTime.now().minusHours(2)); + t.setExpiresAt(LocalDateTime.now().minusHours(1)); + t.setStorageKey(key); + taskRepository.save(t); + mockMvc.perform(get("/profile/me/data-export-tasks/" + t.getId() + "/download") + .header("Authorization", token(uid, User.Role.STUDENT))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9404)); + } + + @Test + void otherUser_task_detailAndDownload_403() throws Exception { + long owner = createUser(User.Role.STUDENT); + long other = createUser(User.Role.STUDENT); + Long taskId = pendingTask(owner).getId(); + String otherToken = token(other, User.Role.STUDENT); + mockMvc.perform(get("/profile/me/data-export-tasks/" + taskId).header("Authorization", otherToken)) + .andExpect(status().isForbidden()); + mockMvc.perform(get("/profile/me/data-export-tasks/" + taskId + "/download").header("Authorization", otherToken)) + .andExpect(status().isForbidden()); + } + + @Test + void list_statusFilter_ownTasksOnly() throws Exception { + long uid = createUser(User.Role.STUDENT); + pendingTask(uid); + String tk = token(uid, User.Role.STUDENT); + mockMvc.perform(get("/profile/me/data-export-tasks").header("Authorization", tk) + .param("status", "PENDING")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.items.length()").value(1)); + } + + @Test + void deletionPending_createBlocked_409_9302() throws Exception { + long uid = createUser(User.Role.STUDENT); + deletionRepository.save(AccountDeletion.builder() + .userId(uid).status(DeletionStatus.PENDING).requestedAt(LocalDateTime.now()).version(0L).build()); + mockMvc.perform(post("/profile/me/data-export-tasks") + .header("Authorization", token(uid, User.Role.STUDENT)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"format\":\"ZIP\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9302)); + } + + @Test + void deletionCompleted_allEndpointsBlocked_409_9303() throws Exception { + long uid = createUser(User.Role.STUDENT); + deletionRepository.save(AccountDeletion.builder() + .userId(uid).status(DeletionStatus.COMPLETED).completedAt(LocalDateTime.now()).version(0L).build()); + String tk = token(uid, User.Role.STUDENT); + mockMvc.perform(post("/profile/me/data-export-tasks") + .header("Authorization", tk).contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9303)); + mockMvc.perform(get("/profile/me/data-export-tasks").header("Authorization", tk)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9303)); + mockMvc.perform(get("/profile/me/data-export-tasks/1").header("Authorization", tk)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9303)); + } + + @Test + void response_doesNotLeakSensitiveFields() throws Exception { + long uid = createUser(User.Role.STUDENT); + String body = mockMvc.perform(post("/profile/me/data-export-tasks") + .header("Authorization", token(uid, User.Role.STUDENT)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"format\":\"ZIP\"}")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + String normalized = body.toLowerCase(); + assertThat(normalized).doesNotContain("storagekey"); + assertThat(normalized).doesNotContain("path"); + assertThat(normalized).doesNotContain("password"); + assertThat(normalized).doesNotContain("phone"); + assertThat(normalized).doesNotContain("jwt"); + } + + @Test + void create_persistsRequestedAudit_withSafeDetails() throws Exception { + long uid = createUser(User.Role.STUDENT); + mockMvc.perform(post("/profile/me/data-export-tasks") + .header("Authorization", token(uid, User.Role.STUDENT)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"format\":\"ZIP\"}")) + .andExpect(status().isOk()); + AuditLog req = auditLogRepository.findAll().stream() + .filter(a -> "DATA_EXPORT_REQUESTED".equals(a.getAction())) + .findFirst().orElse(null); + assertThat(req).isNotNull(); + String d = req.getDetails().toLowerCase(); + assertThat(d).contains("taskid").contains("userid").contains("format").contains("status"); + assertThat(d).doesNotContain("phone").doesNotContain("email").doesNotContain("password") + .doesNotContain("jwt").doesNotContain("storagekey").doesNotContain("secret"); + } + + @Test + void download_auditsDownloadedOnlyOnce() throws Exception { + long uid = createUser(User.Role.STUDENT); + Long taskId = completedTaskWithFile(uid); + String tk = token(uid, User.Role.STUDENT); + mockMvc.perform(get("/profile/me/data-export-tasks/" + taskId + "/download") + .header("Authorization", tk)) + .andExpect(status().isOk()); + mockMvc.perform(get("/profile/me/data-export-tasks/" + taskId + "/download") + .header("Authorization", tk)) + .andExpect(status().isOk()); + long count = auditLogRepository.findAll().stream() + .filter(a -> "DATA_EXPORT_DOWNLOADED".equals(a.getAction())) + .count(); + assertThat(count).isEqualTo(1L); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportPersistenceIntegrationTest.java new file mode 100644 index 0000000..ac94ca5 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportPersistenceIntegrationTest.java @@ -0,0 +1,167 @@ +package com.mydreamjob.dataexport; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 个人数据导出持久化集成测试(H2 MODE=MySQL)。 + * + *

覆盖:V30 建表、外键、CHECK(status/format)、version 递增、幂等键唯一约束、多个 NULL key、 + * 导出任务不对既有业务表写入任何数据。 + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class PersonalDataExportPersistenceIntegrationTest { + + @Autowired private PersonalDataExportTaskRepository taskRepository; + @Autowired private UserRepository userRepository; + @Autowired private EntityManager entityManager; + + @MockBean private org.springframework.data.redis.core.RedisTemplate redisTemplate; + + private static final AtomicLong PHONE = new AtomicLong(933_000_000L); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + org.springframework.data.redis.core.ValueOperations ops = + mock(org.springframework.data.redis.core.ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + } + + @AfterEach + void tearDown() { + taskRepository.deleteAll(); + userRepository.deleteAll(); + } + + private Long createStudent() { + User u = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .build()); + return u.getId(); + } + + @Test + void table_exists_andTaskPersists() { + Long uid = createStudent(); + PersonalDataExportTask t = taskRepository.save(PersonalDataExportTask.builder() + .userId(uid).format(PersonalDataExportTask.FORMAT_ZIP) + .idempotencyKey("k" + System.nanoTime()).build()); + assertNotNull(t.getId()); + assertEquals(PersonalDataExportTask.STATUS_PENDING, t.getStatus()); + assertNotNull(t.getRequestedAt()); + entityManager.clear(); + assertNotNull(taskRepository.findById(t.getId()).orElseThrow()); + } + + @Test + void idempotencyKey_uniquePerUser_duplicateRejected() { + Long uid = createStudent(); + taskRepository.saveAndFlush(PersonalDataExportTask.builder() + .userId(uid).format(PersonalDataExportTask.FORMAT_ZIP).idempotencyKey("dup").build()); + assertThrows(DataIntegrityViolationException.class, () -> + taskRepository.saveAndFlush(PersonalDataExportTask.builder() + .userId(uid).format(PersonalDataExportTask.FORMAT_ZIP).idempotencyKey("dup").build())); + } + + @Test + void multipleNullKeys_allowed() { + Long uid = createStudent(); + taskRepository.saveAndFlush(PersonalDataExportTask.builder() + .userId(uid).format(PersonalDataExportTask.FORMAT_ZIP).build()); + taskRepository.saveAndFlush(PersonalDataExportTask.builder() + .userId(uid).format(PersonalDataExportTask.FORMAT_ZIP).build()); + assertEquals(2, taskRepository.findByUserIdOrderByCreatedAtDesc(uid, + org.springframework.data.domain.PageRequest.of(0, 20)).getTotalElements()); + } + + @Test + @org.springframework.transaction.annotation.Transactional + void statusCheck_rejectsInvalidValue() { + Long uid = createStudent(); + assertThrows(PersistenceException.class, () -> entityManager + .createNativeQuery("INSERT INTO personal_data_export_tasks (user_id, status, format, requested_at)" + + " VALUES (:uid, 'BOGUS', 'ZIP', CURRENT_TIMESTAMP)") + .setParameter("uid", uid).executeUpdate()); + entityManager.clear(); + } + + @Test + @org.springframework.transaction.annotation.Transactional + void formatCheck_rejectsInvalidValue() { + Long uid = createStudent(); + assertThrows(PersistenceException.class, () -> entityManager + .createNativeQuery("INSERT INTO personal_data_export_tasks (user_id, status, format, requested_at)" + + " VALUES (:uid, 'PENDING', 'PDF', CURRENT_TIMESTAMP)") + .setParameter("uid", uid).executeUpdate()); + entityManager.clear(); + } + + @Test + @org.springframework.transaction.annotation.Transactional + void foreignKey_rejectsUnknownUser() { + assertThrows(PersistenceException.class, () -> entityManager + .createNativeQuery("INSERT INTO personal_data_export_tasks (user_id, status, format, requested_at)" + + " VALUES (9999999999, 'PENDING', 'ZIP', CURRENT_TIMESTAMP)") + .executeUpdate()); + entityManager.clear(); + } + + @Test + void version_incrementsOnUpdate() { + Long uid = createStudent(); + PersonalDataExportTask t = taskRepository.save(PersonalDataExportTask.builder() + .userId(uid).format(PersonalDataExportTask.FORMAT_ZIP).build()); + assertEquals(0L, t.getVersion()); + t.setStatus(PersonalDataExportTask.STATUS_COMPLETED); + PersonalDataExportTask updated = taskRepository.saveAndFlush(t); + assertEquals(1L, updated.getVersion()); + } + + @Test + void exportTask_doesNotWriteAnyBusinessTable() { + Long uid = createStudent(); + long beforeApps = rowCount("applications"); + long beforeOffers = rowCount("offers"); + long beforeNotifs = rowCount("notifications"); + long beforeReviews = rowCount("job_reviews"); + + taskRepository.saveAndFlush(PersonalDataExportTask.builder() + .userId(uid).format(PersonalDataExportTask.FORMAT_ZIP).build()); + + assertEquals(beforeApps, rowCount("applications"), "V30 不得写入 applications"); + assertEquals(beforeOffers, rowCount("offers"), "V30 不得写入 offers"); + assertEquals(beforeNotifs, rowCount("notifications"), "V30 不得写入 notifications"); + assertEquals(beforeReviews, rowCount("job_reviews"), "V30 不得写入 job_reviews"); + } + + private long rowCount(String table) { + return ((Number) entityManager.createNativeQuery("SELECT COUNT(*) FROM " + table) + .getSingleResult()).longValue(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportWorkerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportWorkerTest.java new file mode 100644 index 0000000..a76d4d2 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportWorkerTest.java @@ -0,0 +1,148 @@ +package com.mydreamjob.dataexport; + +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.dataexport.storage.PersonalDataExportStorageService; +import com.mydreamjob.deletion.AccountDeletionService; +import com.mydreamjob.deletion.DeletionStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PersonalDataExportWorkerTest { + + private PersonalDataExportTaskRepository taskRepository; + private PersonalDataCollector collector; + private PersonalDataExportBuilder builder; + private PersonalDataExportStorageService storageService; + private AuditService auditService; + private AccountDeletionService deletionService; + private PersonalDataExportWorker worker; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + taskRepository = mock(PersonalDataExportTaskRepository.class); + collector = mock(PersonalDataCollector.class); + builder = mock(PersonalDataExportBuilder.class); + storageService = mock(PersonalDataExportStorageService.class); + auditService = mock(AuditService.class); + deletionService = mock(AccountDeletionService.class); + worker = new PersonalDataExportWorker( + taskRepository, collector, builder, storageService, auditService, deletionService); + ReflectionTestUtils.setField(worker, "fileTtlHours", 24L); + when(deletionService.effectiveStatus(anyLong())).thenReturn(DeletionStatus.NONE); + when(collector.collect(anyLong())).thenReturn(new PersonalDataCollector.CollectResult()); + when(builder.build(anyLong(), any(PersonalDataCollector.CollectResult.class))) + .thenReturn(new PersonalDataExportBuilder.Result(new byte[]{0x50, 0x4B, 0x03, 0x04}, "abc")); + when(storageService.store(anyLong(), anyLong(), any(byte[].class))).thenReturn("100/1.zip"); + } + + private PersonalDataExportTask pendingTask(Long id, Long userId) { + return PersonalDataExportTask.builder() + .id(id).userId(userId) + .status(PersonalDataExportTask.STATUS_PENDING) + .format(PersonalDataExportTask.FORMAT_ZIP) + .requestedAt(LocalDateTime.now()) + .version(0L) + .build(); + } + + @Test + void execute_pending_upgradesToCompleted_once() { + PersonalDataExportTask task = pendingTask(1L, 100L); + when(taskRepository.findById(1L)).thenReturn(Optional.of(task)); + + worker.execute(1L); + + assertThat(task.getStatus()).isEqualTo(PersonalDataExportTask.STATUS_COMPLETED); + assertThat(task.getStorageKey()).isEqualTo("100/1.zip"); + assertThat(task.getExpiresAt()).isNotNull(); + verify(taskRepository, atLeastOnce()).saveAndFlush(task); + verify(auditService).log(eq(100L), eq("DATA_EXPORT_COMPLETED"), anyString(), anyLong(), anyString(), + any(), any(), any()); + } + + @Test + void execute_nonPending_skips() { + PersonalDataExportTask processing = pendingTask(1L, 100L); + processing.setStatus(PersonalDataExportTask.STATUS_PROCESSING); + when(taskRepository.findById(1L)).thenReturn(Optional.of(processing)); + + worker.execute(1L); + + assertThat(processing.getStatus()).isEqualTo(PersonalDataExportTask.STATUS_PROCESSING); + verify(storageService, never()).store(anyLong(), anyLong(), any(byte[].class)); + } + + @Test + void execute_deletionPendingOrCompleted_marksFailed_noFileGenerated() { + PersonalDataExportTask task = pendingTask(1L, 100L); + when(taskRepository.findById(1L)).thenReturn(Optional.of(task)); + when(deletionService.effectiveStatus(100L)).thenReturn(DeletionStatus.PENDING); + + worker.execute(1L); + + assertThat(task.getStatus()).isEqualTo(PersonalDataExportTask.STATUS_FAILED); + assertThat(task.getStorageKey()).isNull(); + verify(storageService, never()).store(anyLong(), anyLong(), any(byte[].class)); + } + + @Test + void execute_processingException_marksFailed() { + PersonalDataExportTask task = pendingTask(1L, 100L); + when(taskRepository.findById(1L)).thenReturn(Optional.of(task)); + when(collector.collect(100L)).thenThrow(new IllegalStateException("boom")); + + worker.execute(1L); + + assertThat(task.getStatus()).isEqualTo(PersonalDataExportTask.STATUS_FAILED); + assertThat(task.getErrorCode()).isNotBlank(); + } + + @Test + void execute_optimisticLockConflict_skips() { + PersonalDataExportTask task = pendingTask(1L, 100L); + when(taskRepository.findById(1L)).thenReturn(Optional.of(task)); + // 抢占失败:第一个 saveAndFlush(PENDING->PROCESSING) 抛乐观锁冲突 + when(taskRepository.saveAndFlush(any(PersonalDataExportTask.class))) + .thenThrow(ObjectOptimisticLockingFailureException.class); + + worker.execute(1L); + + assertThat(task.getErrorCode()).isNull(); + verify(storageService, never()).store(anyLong(), anyLong(), any(byte[].class)); + } + + @Test + void expireCompletedTasks_marksExpired_andDeletesFile() { + PersonalDataExportTask done = pendingTask(2L, 100L); + done.setStatus(PersonalDataExportTask.STATUS_COMPLETED); + done.setStorageKey("100/2.zip"); + done.setExpiresAt(LocalDateTime.now().minusMinutes(1)); + // 第二个参数为时间边界:用 any(LocalDateTime.class) 匹配,避免 now() 纳秒不一致导致桩不命中 + when(taskRepository.findByStatusAndExpiresAtBefore( + eq(PersonalDataExportTask.STATUS_COMPLETED), any(LocalDateTime.class))) + .thenReturn(List.of(done)); + + worker.expireCompletedTasks(); + + assertThat(done.getStatus()).isEqualTo(PersonalDataExportTask.STATUS_EXPIRED); + verify(storageService).delete("100/2.zip"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java new file mode 100644 index 0000000..842fbaa --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java @@ -0,0 +1,301 @@ +package com.mydreamjob.deletion; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 账号注销接口契约集成测试。 + * + *

覆盖:POST/GET /profile/me/deletion、POST /profile/me/deletion/cancel 的正常与错误码, + * 未登录 401,userId 参数注入无效,DTO 无敏感字段,OpenAPI 一致性。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class AccountDeletionControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private AccountDeletionRepository deletionRepository; + + @MockBean private org.springframework.data.redis.core.RedisTemplate redisTemplate; + + private final ObjectMapper mapper = new ObjectMapper(); + private static final AtomicLong PHONE = new AtomicLong(930_000_000L); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + org.springframework.data.redis.core.ValueOperations ops = + mock(org.springframework.data.redis.core.ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + } + + @AfterEach + void tearDown() { + // 清理共享 H2 库(integrationdb)中的注销记录与测试用户,避免残留 PENDING/COMPLETED + // 污染后续其他集成测试类(其使用固定 userId 触发注销守卫)。 + deletionRepository.deleteAll(); + userRepository.deleteAll(); + } + + private long createUser(User.Role role) { + User u = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .roles(Set.of(role)) + .role(role) + .active(true) + .build()); + return u.getId(); + } + + private String token(long userId) { + return "Bearer " + jwtTokenProvider.generateAccessToken( + userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT")); + } + + @Test + void initiate_thenGetStatus_returnsPending() throws Exception { + long uid = createUser(User.Role.STUDENT); + String body = mockMvc.perform(post("/profile/me/deletion") + .header("Authorization", token(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.userId").value(uid)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + + JsonNode data = mapper.readTree(body).path("data"); + assertFalse(data.hasNonNull("phone"), "响应不得含 phone"); + assertFalse(data.hasNonNull("email"), "响应不得含 email"); + + mockMvc.perform(get("/profile/me/deletion") + .header("Authorization", token(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PENDING")); + } + + @Test + void initiate_whenCompleted_returns409_9301() throws Exception { + long uid = createUser(User.Role.STUDENT); + deletionRepository.save(AccountDeletion.builder() + .userId(uid).status(DeletionStatus.COMPLETED).completedAt(LocalDateTime.now()).version(0L) + .build()); + + mockMvc.perform(post("/profile/me/deletion") + .header("Authorization", token(uid))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9301)); + } + + @Test + void initiate_repeatPending_isIdempotent_200() throws Exception { + long uid = createUser(User.Role.STUDENT); + mockMvc.perform(post("/profile/me/deletion").header("Authorization", token(uid))) + .andExpect(status().isOk()); + + mockMvc.perform(post("/profile/me/deletion").header("Authorization", token(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PENDING")); + } + + @Test + void cancel_pending_returnsNone() throws Exception { + long uid = createUser(User.Role.STUDENT); + mockMvc.perform(post("/profile/me/deletion").header("Authorization", token(uid))) + .andExpect(status().isOk()); + + mockMvc.perform(post("/profile/me/deletion/cancel").header("Authorization", token(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("NONE")); + } + + @Test + void cancel_whenCompleted_returns9315ish_409() throws Exception { + long uid = createUser(User.Role.STUDENT); + deletionRepository.save(AccountDeletion.builder() + .userId(uid).status(DeletionStatus.COMPLETED).completedAt(LocalDateTime.now()).version(0L) + .build()); + + mockMvc.perform(post("/profile/me/deletion/cancel").header("Authorization", token(uid))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code", org.hamcrest.Matchers.in(new Integer[]{9305, 9301}))); + } + + @Test + void cancel_neverInitiated_isIdempotent_200NONE() throws Exception { + long uid = createUser(User.Role.STUDENT); + mockMvc.perform(post("/profile/me/deletion/cancel").header("Authorization", token(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("NONE")); + } + + @Test + void getStatus_NoRecord_returnsDefaultNone_noInsert() throws Exception { + long uid = createUser(User.Role.STUDENT); + long before = deletionRepository.count(); + + mockMvc.perform(get("/profile/me/deletion").header("Authorization", token(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("NONE")) + .andExpect(jsonPath("$.data.version").value(0)); + + assertEquals(before, deletionRepository.count(), "GET 无记录不应自动插入"); + assertFalse(deletionRepository.findByUserId(uid).isPresent()); + } + + @Test + void deletion_areAllowedForAnyRole() throws Exception { + long company = createUser(User.Role.COMPANY); + mockMvc.perform(post("/profile/me/deletion").header("Authorization", token(company))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PENDING")); + + long admin = createUser(User.Role.ADMIN); + mockMvc.perform(get("/profile/me/deletion").header("Authorization", token(admin))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("NONE")); + } + + @Test + void deletion_noToken_returns401() throws Exception { + mockMvc.perform(post("/profile/me/deletion")).andExpect(status().isUnauthorized()); + mockMvc.perform(get("/profile/me/deletion")).andExpect(status().isUnauthorized()); + mockMvc.perform(post("/profile/me/deletion/cancel")).andExpect(status().isUnauthorized()); + } + + @Test + void initiate_ignoresUserIdParam_usesJwtIdentity() throws Exception { + long uid = createUser(User.Role.STUDENT); + + mockMvc.perform(post("/profile/me/deletion") + .header("Authorization", token(uid)) + .param("userId", String.valueOf(uid * 999 + 1))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").value(uid)); + } + + @Test + void responses_forbidSensitiveFields() throws Exception { + long uid = createUser(User.Role.STUDENT); + mockMvc.perform(post("/profile/me/deletion").header("Authorization", token(uid))) + .andExpect(status().isOk()); + + String body = mockMvc.perform(get("/profile/me/deletion").header("Authorization", token(uid))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + String normalized = body.toLowerCase(); + assertFalse(normalized.contains("password")); + assertFalse(normalized.contains("phone")); + assertFalse(normalized.contains("email")); + assertFalse(normalized.contains("jwt")); + assertFalse(normalized.contains("resumeurl")); + } + + @Test + void lazyTransition_firstGet_returnsCompleted_andConsistent() throws Exception { + long uid = createUser(User.Role.STUDENT); + LocalDateTime past = LocalDateTime.now().minusDays(1); + deletionRepository.save(AccountDeletion.builder() + .userId(uid).status(DeletionStatus.PENDING) + .requestedAt(past.minusDays(30)).effectiveAt(past).version(0L) + .build()); + + // 第一次 GET:懒转换完成,必须直接返回 COMPLETED(不允许返回旧的 PENDING) + mockMvc.perform(get("/profile/me/deletion").header("Authorization", token(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("COMPLETED")) + .andExpect(jsonPath("$.data.completedAt").isNotEmpty()); + + // 数据库已落库为 COMPLETED,用户已脱敏 + assertEquals(DeletionStatus.COMPLETED, + deletionRepository.findByUserId(uid).orElseThrow().getStatus()); + + // 第二次 GET 仍应为 COMPLETED,不能出现"第一次 PENDING、第二次 COMPLETED"的不一致 + mockMvc.perform(get("/profile/me/deletion").header("Authorization", token(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("COMPLETED")); + } + + @Test + void completedUser_oldJwt_blockedForBusiness_butDeletionEndpointsAllowed() throws Exception { + long uid = createUser(User.Role.STUDENT); + String oldJwt = token(uid); // 该 JWT 在注销前即已签发,作为"旧 JWT" + deletionRepository.save(AccountDeletion.builder() + .userId(uid).status(DeletionStatus.COMPLETED).completedAt(LocalDateTime.now()).version(0L) + .build()); + + // 旧 JWT 访问普通业务 GET → 409/9303 + mockMvc.perform(get("/profile/me/privacy").header("Authorization", oldJwt)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9303)); + + // 普通业务写操作(拦截器在到达控制器前即拦截)→ 409/9303 + mockMvc.perform(put("/profile/me/privacy").header("Authorization", oldJwt)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9303)); + + // 注销接口允许继续访问以查询终态或返回正确错误 + mockMvc.perform(get("/profile/me/deletion").header("Authorization", oldJwt)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("COMPLETED")); + + mockMvc.perform(post("/profile/me/deletion").header("Authorization", oldJwt)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9301)); + + mockMvc.perform(post("/profile/me/deletion/cancel").header("Authorization", oldJwt)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code", org.hamcrest.Matchers.in(new Integer[]{9301, 9305}))); + } + + @Test + void openApi_documentsDeletionContract() throws Exception { + String json = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode root = mapper.readTree(json); + + JsonNode path = root.path("paths").path("/profile/me/deletion"); + assertThat(path.path("get").isMissingNode()).isFalse(); + assertThat(path.path("post").isMissingNode()).isFalse(); + JsonNode cancel = root.path("paths").path("/profile/me/deletion/cancel"); + assertThat(cancel.path("post").isMissingNode()).isFalse(); + + assertThat(path.toString().contains("AccountDeletionResponse")).isTrue(); + assertThat(path.path("get").path("security").toString().contains("bearerAuth")).isTrue(); + assertThat(path.path("post").path("security").toString().contains("bearerAuth")).isTrue(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionPersistenceIntegrationTest.java new file mode 100644 index 0000000..bd2aaef --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionPersistenceIntegrationTest.java @@ -0,0 +1,266 @@ +package com.mydreamjob.deletion; + +import com.mydreamjob.audit.AuditLog; +import com.mydreamjob.audit.AuditLogRepository; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 账号注销持久化集成测试(H2 MODE=MySQL)。 + * + *

覆盖:V28 建表可用、user_id 唯一约束、status CHECK、外键约束、version 递增、 + * 完成态脱敏幂等、审计日志存在且不含敏感值、脱敏后业务/身份记录仍保留。 + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class AccountDeletionPersistenceIntegrationTest { + + @Autowired private AccountDeletionService service; + @Autowired private AccountDeletionRepository deletionRepository; + @Autowired private AccountDeletionCompletionWorker worker; + @Autowired private UserRepository userRepository; + @Autowired private AuditLogRepository auditLogRepository; + @Autowired private EntityManager entityManager; + + @MockBean private org.springframework.data.redis.core.RedisTemplate redisTemplate; + + private static final AtomicLong PHONE = new AtomicLong(931_000_000L); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + org.springframework.data.redis.core.ValueOperations ops = + mock(org.springframework.data.redis.core.ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + } + + @AfterEach + void tearDown() { + // 清理共享 H2 库(integrationdb)中的注销记录,避免残留 PENDING/COMPLETED + // 污染后续其他集成测试类(其使用固定 userId 触发注销守卫)。 + deletionRepository.deleteAll(); + auditLogRepository.deleteAll(); + userRepository.deleteAll(); + } + + private Long createStudent() { + User u = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .nickname("注销测试学生") + .email("delete-me@test.local") + .passwordHash("$2a$10$dummyhash") + .avatarUrl("http://x/a.png") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()); + return u.getId(); + } + + @Test + void tableExists_andUniqueUserId_violatesOnSecondRow() { + Long uid = createStudent(); + deletionRepository.save(AccountDeletion.builder() + .userId(uid).status(DeletionStatus.NONE).version(0L).build()); + + assertThrows(DataIntegrityViolationException.class, () -> + deletionRepository.saveAndFlush(AccountDeletion.builder() + .userId(uid).status(DeletionStatus.PENDING).version(0L).build())); + } + + @Test + @org.springframework.transaction.annotation.Transactional + void statusCheckConstraint_rejectsInvalidValue() { + Long invalidUserId = createStudent(); + // 直接构造 native insert 验证 DB 层 CHECK 约束 + assertThrows(PersistenceException.class, () -> entityManager + .createNativeQuery( + "INSERT INTO account_deletions (user_id, status) VALUES (:uid, 'BOGUS')") + .setParameter("uid", invalidUserId) + .executeUpdate()); + entityManager.clear(); + } + + @Test + @org.springframework.transaction.annotation.Transactional + void foreignKey_rejectsUnknownUser() { + assertThrows(PersistenceException.class, () -> entityManager + .createNativeQuery( + "INSERT INTO account_deletions (user_id, status) VALUES (:uid, 'NONE')") + .setParameter("uid", 9_999_999_999L) + .executeUpdate()); + entityManager.clear(); + } + + @Test + void version_incrementsOnUpdate() { + Long uid = createStudent(); + AccountDeletion rec = deletionRepository.save(AccountDeletion.builder() + .userId(uid).status(DeletionStatus.PENDING).version(0L).build()); + + assertEquals(0L, rec.getVersion()); + rec.setStatus(DeletionStatus.NONE); + AccountDeletion updated = deletionRepository.saveAndFlush(rec); + // 注意:修改同状态的 status 需避免命中 check,NONE 合法 + assertEquals(1L, updated.getVersion()); + } + + @Test + void initiate_persistsPendingAndAudit() { + Long uid = createStudent(); + service.initiate(uid); + + entityManager.clear(); + AccountDeletion rec = deletionRepository.findByUserId(uid).orElseThrow(); + assertEquals(DeletionStatus.PENDING, rec.getStatus()); + assertNotNull(rec.getRequestedAt()); + + List initLogs = auditLogRepository.findAll().stream() + .filter(l -> uid.equals(l.getUserId()) && "ACCOUNT_DELETION_INITIATED".equals(l.getAction())) + .collect(Collectors.toList()); + assertEquals(1, initLogs.size(), "申请注销审计应恰好一次"); + } + + @Test + void completed_desensitizesUser_butKeepsIdentityAndBusinessRecord() { + Long uid = createStudent(); + // 构造一个已过期 PENDING,触发完成并脱敏 + LocalDateTime past = LocalDateTime.now().minusDays(1); + deletionRepository.save(AccountDeletion.builder() + .userId(uid).status(DeletionStatus.PENDING) + .requestedAt(past.minusDays(30)).effectiveAt(past).version(0L).build()); + + boolean completed = worker.completeIfExpired(uid); + assertTrue(completed, "应完成迁移"); + + entityManager.clear(); + User u = userRepository.findById(uid).orElseThrow(); + assertEquals("deleted_" + uid, u.getPhone(), "phone 应脱敏为 deleted_{id}"); + assertNull(u.getEmail()); + assertNull(u.getPasswordHash()); + assertEquals("deleted", u.getNickname()); + assertFalse(u.getActive()); + // 身份/业务记录仍保留(未物理删除)。避免触发懒加载,直接查询 user_roles 行数 + Number roles = (Number) entityManager + .createNativeQuery("SELECT COUNT(*) FROM user_roles WHERE user_id = :uid") + .setParameter("uid", uid) + .getSingleResult(); + assertTrue(roles.longValue() > 0, "业务关联(user_roles)不得删除"); + + // 完成 + 脱敏审计各一次 + List done = auditLogRepository.findAll().stream() + .filter(l -> uid.equals(l.getUserId()) + && ("ACCOUNT_DELETION_COMPLETED".equals(l.getAction()) + || "ACCOUNT_DATA_DESENSITIZED".equals(l.getAction()))) + .collect(Collectors.toList()); + assertEquals(2, done.size(), "完成+脱敏审计各一次"); + for (AuditLog l : done) { + String d = l.getDetails() == null ? "" : l.getDetails().toLowerCase(); + assertFalse(d.contains("phone"), "审计不得含 phone"); + assertFalse(d.contains("email"), "审计不得含 email"); + assertFalse(d.contains("password"), "审计不得含 password"); + assertFalse(d.contains("jwt"), "审计不得含 jwt"); + assertFalse(d.contains("resumeurl"), "审计不得含 resumeUrl"); + } + } + + @Test + void completion_isIdempotent_secondCall_noExtraAudit() { + Long uid = createStudent(); + LocalDateTime past = LocalDateTime.now().minusDays(1); + deletionRepository.save(AccountDeletion.builder() + .userId(uid).status(DeletionStatus.PENDING) + .requestedAt(past.minusDays(30)).effectiveAt(past).version(0L).build()); + + assertTrue(worker.completeIfExpired(uid)); + assertFalse(worker.completeIfExpired(uid), "第二次调用应幂等返回 false"); + + List done = auditLogRepository.findAll().stream() + .filter(l -> uid.equals(l.getUserId()) + && ("ACCOUNT_DELETION_COMPLETED".equals(l.getAction()) + || "ACCOUNT_DATA_DESENSITIZED".equals(l.getAction()))) + .collect(Collectors.toList()); + assertEquals(2, done.size(), "同一次完成流程不得重复写完成/脱敏审计"); + } + + @Test + void completed_storesPhoneTombstoneHash_notPlaintext_andUniqueQueryable() { + Long uid = createStudent(); + String originalPhone = userRepository.findById(uid).orElseThrow().getPhone(); + LocalDateTime past = LocalDateTime.now().minusDays(1); + deletionRepository.save(AccountDeletion.builder() + .userId(uid).status(DeletionStatus.PENDING) + .requestedAt(past.minusDays(30)).effectiveAt(past).version(0L).build()); + + assertTrue(worker.completeIfExpired(uid), "应完成迁移并写入墓碑"); + entityManager.clear(); + + AccountDeletion rec = deletionRepository.findByUserId(uid).orElseThrow(); + assertNotNull(rec.getDeletedPhoneHash(), "完成时应保存手机号墓碑(HMAC 摘要)"); + assertEquals(64, rec.getDeletedPhoneHash().length(), "HMAC-SHA256 十六进制长度为 64"); + assertNotEquals(originalPhone, rec.getDeletedPhoneHash(), "不得把明文手机号写入注销表"); + + // 可按哈希唯一命中(禁止原手机号重新注册的依据) + assertTrue(deletionRepository.findByDeletedPhoneHash(rec.getDeletedPhoneHash()).isPresent()); + + // 用户 phone 仍按脱敏规则置为 deleted_{id},未物理删除身份/业务记录 + User u = userRepository.findById(uid).orElseThrow(); + assertEquals("deleted_" + uid, u.getPhone()); + Number roles = (Number) entityManager + .createNativeQuery("SELECT COUNT(*) FROM user_roles WHERE user_id = :uid") + .setParameter("uid", uid) + .getSingleResult(); + assertTrue(roles.longValue() > 0, "业务关联(user_roles)不得删除"); + + // 审计日志不得含明文手机号 + List all = auditLogRepository.findAll(); + for (AuditLog l : all) { + String d = l.getDetails() == null ? "" : l.getDetails(); + assertFalse(d.contains(originalPhone), "审计不得保存原手机号明文"); + } + } + + @Test + void lazyTransition_getCurrentStatus_returnsCompleted_onFirstCall() { + Long uid = createStudent(); + LocalDateTime past = LocalDateTime.now().minusDays(1); + deletionRepository.save(AccountDeletion.builder() + .userId(uid).status(DeletionStatus.PENDING) + .requestedAt(past.minusDays(30)).effectiveAt(past).version(0L).build()); + + com.mydreamjob.deletion.dto.AccountDeletionResponse first = service.getCurrentStatus(uid); + // 第一次查询即返回 COMPLETED(不在外层事务中返回过期的 PENDING) + assertEquals(DeletionStatus.COMPLETED, first.getStatus()); + assertNotNull(first.getCompletedAt()); + + entityManager.clear(); + assertEquals(DeletionStatus.COMPLETED, + deletionRepository.findByUserId(uid).orElseThrow().getStatus()); + + // 第二次查询仍返回 COMPLETED,保持一致 + assertEquals(DeletionStatus.COMPLETED, service.getCurrentStatus(uid).getStatus()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionServiceTest.java new file mode 100644 index 0000000..3a8cbd7 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionServiceTest.java @@ -0,0 +1,263 @@ +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 org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.orm.ObjectOptimisticLockingFailureException; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * 账号注销服务状态机单元测试。 + */ +@ExtendWith(MockitoExtension.class) +class AccountDeletionServiceTest { + + @Mock private AccountDeletionRepository repo; + @Mock private UserRepository userRepository; + @Mock private AuditService auditService; + @Mock private AccountDeletionCompletionWorker worker; + @Mock private AccountDeletionReadService readService; + @Mock private EntityManager entityManager; + + private AccountDeletionService svc() { + return new AccountDeletionService(repo, userRepository, auditService, worker, readService, entityManager, 30L); + } + + private void stubStudent(long uid) { + User u = User.builder().id(uid).phone("13800000001") + .role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)) + .active(true).build(); + when(userRepository.findById(uid)).thenReturn(Optional.of(u)); + } + + private AccountDeletion pendingRec(long uid, LocalDateTime effectiveAt) { + return AccountDeletion.builder() + .id(1L).userId(uid).status(DeletionStatus.PENDING) + .requestedAt(LocalDateTime.now()).effectiveAt(effectiveAt).version(0L) + .build(); + } + + @Test + void initiate_NoRecord_goesPending_with30DayEffectiveAt() { + long uid = 1L; + stubStudent(uid); + when(repo.findByUserId(uid)).thenReturn(Optional.empty()); + when(repo.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + + AccountDeletionResponse r = svc().initiate(uid); + + assertEquals(DeletionStatus.PENDING, r.getStatus()); + assertNotNull(r.getRequestedAt()); + assertEquals(r.getRequestedAt().plusDays(30), r.getEffectiveAt()); + verify(auditService, times(1)).log(eq(uid), eq("ACCOUNT_DELETION_INITIATED"), + any(), any(), any(), isNull(), isNull(), isNull()); + } + + @Test + void initiate_AlreadyPending_isIdempotent_noDuplicateAudit() { + long uid = 1L; + stubStudent(uid); + AccountDeletion existing = pendingRec(uid, LocalDateTime.now().plusDays(30)); + when(repo.findByUserId(uid)).thenReturn(Optional.of(existing)); + + AccountDeletionResponse r = svc().initiate(uid); + + assertEquals(DeletionStatus.PENDING, r.getStatus()); + verify(auditService, never()).log(any(), any(), any(), any(), any(), + any(), any(), any()); + verify(repo, never()).saveAndFlush(any()); + } + + @Test + void initiate_Completed_returnsInvalidDeletionState() { + long uid = 1L; + stubStudent(uid); + AccountDeletion completed = AccountDeletion.builder().id(1L).userId(uid) + .status(DeletionStatus.COMPLETED).completedAt(LocalDateTime.now()).version(1L).build(); + when(repo.findByUserId(uid)).thenReturn(Optional.of(completed)); + + BusinessException ex = assertThrows(BusinessException.class, () -> svc().initiate(uid)); + assertEquals(ErrorCode.INVALID_DELETION_STATE.getCode(), ex.getErrorCode()); + } + + @Test + void initiate_ExpiredPending_isCompletedFirst_then9301() { + long uid = 1L; + stubStudent(uid); + AccountDeletion expired = pendingRec(uid, LocalDateTime.now().minusDays(1)); + when(repo.findByUserId(uid)).thenReturn(Optional.of(expired)); + // 懒转换:worker 完成 + when(worker.completeIfExpired(uid)).thenReturn(true); + + BusinessException ex = assertThrows(BusinessException.class, () -> svc().initiate(uid)); + assertEquals(ErrorCode.INVALID_DELETION_STATE.getCode(), ex.getErrorCode()); + verify(worker).completeIfExpired(uid); + } + + @Test + void cancel_Pending_goesNone_writesCancelledAudit() { + long uid = 1L; + stubStudent(uid); + AccountDeletion pending = pendingRec(uid, LocalDateTime.now().plusDays(30)); + when(repo.findByUserId(uid)).thenReturn(Optional.of(pending)); + when(repo.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + + AccountDeletionResponse r = svc().cancel(uid); + + assertEquals(DeletionStatus.NONE, r.getStatus()); + assertNotNull(r.getCancelledAt()); + verify(auditService, times(1)).log(eq(uid), eq("ACCOUNT_DELETION_CANCELLED"), + any(), any(), any(), isNull(), isNull(), isNull()); + } + + @Test + void cancel_AlreadyNone_isIdempotent() { + long uid = 1L; + stubStudent(uid); + AccountDeletion none = AccountDeletion.builder().id(1L).userId(uid) + .status(DeletionStatus.NONE).version(1L).build(); + when(repo.findByUserId(uid)).thenReturn(Optional.of(none)); + + AccountDeletionResponse r = svc().cancel(uid); + + assertEquals(DeletionStatus.NONE, r.getStatus()); + verify(auditService, never()).log(any(), any(), any(), any(), any(), + any(), any(), any()); + } + + @Test + void cancel_ExpiredPending_throws9305() { + long uid = 1L; + stubStudent(uid); + AccountDeletion expired = pendingRec(uid, LocalDateTime.now().minusDays(1)); + when(repo.findByUserId(uid)).thenReturn(Optional.of(expired)); + when(worker.completeIfExpired(uid)).thenReturn(true); + + BusinessException ex = assertThrows(BusinessException.class, () -> svc().cancel(uid)); + assertEquals(ErrorCode.ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED.getCode(), ex.getErrorCode()); + } + + @Test + void cancel_Completed_notAllowed_toRecover() { + long uid = 1L; + stubStudent(uid); + AccountDeletion completed = AccountDeletion.builder().id(1L).userId(uid) + .status(DeletionStatus.COMPLETED).version(1L).build(); + when(repo.findByUserId(uid)).thenReturn(Optional.of(completed)); + + BusinessException ex = assertThrows(BusinessException.class, () -> svc().cancel(uid)); + assertEquals(ErrorCode.ACCOUNT_DELETION_CANCEL_DEADLINE_PASSED.getCode(), ex.getErrorCode()); + } + + @Test + void getCurrentStatus_NoRecord_returnsDefaultNone_doesNotInsert() { + long uid = 1L; + stubStudent(uid); + when(repo.findByUserId(uid)).thenReturn(Optional.empty()); + + AccountDeletionResponse r = svc().getCurrentStatus(uid); + + assertEquals(DeletionStatus.NONE, r.getStatus()); + assertEquals(0L, r.getVersion()); + verify(repo, never()).saveAndFlush(any()); + verify(repo, never()).save(any()); + } + + @Test + void getCurrentStatus_ExpiredPending_lazyCompletes() { + long uid = 1L; + stubStudent(uid); + AccountDeletion expiredPending = pendingRec(uid, LocalDateTime.now().minusDays(1)); + when(repo.findByUserId(uid)).thenReturn(Optional.of(expiredPending)); + // worker 在 REQUIRES_NEW 独立事务中完成迁移;返回 true 表示懒转换已提交 + when(worker.completeIfExpired(uid)).thenReturn(true); + // 懒转换后必须通过在独立只读事务重读的最新 COMPLETED 记录(模拟新快照) + AccountDeletion latest = pendingRec(uid, LocalDateTime.now().minusDays(1)); + latest.setStatus(DeletionStatus.COMPLETED); + latest.setCompletedAt(LocalDateTime.now()); + when(readService.findLatestByUserId(uid)).thenReturn(latest); + + AccountDeletionResponse r = svc().getCurrentStatus(uid); + + assertEquals(DeletionStatus.COMPLETED, r.getStatus()); + assertNotNull(r.getCompletedAt()); + verify(worker).completeIfExpired(uid); + verify(readService).findLatestByUserId(uid); + } + + @Test + void effectiveStatus_ExpiredPending_completes() { + long uid = 1L; + AccountDeletion expired = pendingRec(uid, LocalDateTime.now().minusDays(1)); + when(repo.findByUserId(uid)).thenReturn(Optional.of(expired)); + + assertEquals(DeletionStatus.COMPLETED, svc().effectiveStatus(uid)); + verify(worker).completeIfExpired(uid); + } + + @Test + void effectiveStatus_NotExpired_returnsPending_withoutCompleting() { + long uid = 1L; + AccountDeletion pending = pendingRec(uid, LocalDateTime.now().plusDays(30)); + when(repo.findByUserId(uid)).thenReturn(Optional.of(pending)); + + assertEquals(DeletionStatus.PENDING, svc().effectiveStatus(uid)); + verify(worker, never()).completeIfExpired(any()); + } + + @Test + void initiate_OptimisticLockConflict_maps9304() { + long uid = 1L; + stubStudent(uid); + AccountDeletion pending = pendingRec(uid, LocalDateTime.now().plusDays(30)); + when(repo.findByUserId(uid)).thenReturn(Optional.of(pending)); + doThrow(new ObjectOptimisticLockingFailureException(AccountDeletion.class, 1L)) + .when(repo).saveAndFlush(any()); + + // 直接验证 cancel 的乐观锁冲突映射到 9304 + BusinessException ex = assertThrows(BusinessException.class, () -> svc().cancel(uid)); + assertEquals(ErrorCode.ACCOUNT_DELETION_CONCURRENT_UPDATE.getCode(), ex.getErrorCode()); + } + + @Test + void initiate_LastActiveAdmin_blocked9306() { + long uid = 42L; + User admin = User.builder().id(uid).phone("13811111111") + .role(User.Role.ADMIN).roles(Set.of(User.Role.ADMIN)).active(true).build(); + when(userRepository.findById(uid)).thenReturn(Optional.of(admin)); + when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(1L); + + BusinessException ex = assertThrows(BusinessException.class, () -> svc().initiate(uid)); + assertEquals(ErrorCode.LAST_ADMIN_CANNOT_DELETE.getCode(), ex.getErrorCode()); + } + + @Test + void initiate_AdminWithOthers_allowed() { + long uid = 42L; + User admin = User.builder().id(uid).phone("13811111111") + .role(User.Role.ADMIN).roles(Set.of(User.Role.ADMIN)).active(true).build(); + when(userRepository.findById(uid)).thenReturn(Optional.of(admin)); + when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(2L); + when(repo.findByUserId(uid)).thenReturn(Optional.empty()); + when(repo.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + + AccountDeletionResponse r = svc().initiate(uid); + + assertEquals(DeletionStatus.PENDING, r.getStatus()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java new file mode 100644 index 0000000..6218e09 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java @@ -0,0 +1,174 @@ +package com.mydreamjob.deletion; + +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 注销守卫拦截器集成测试。 + * + *

覆盖:PENDING 阻断 POST/PUT/PATCH/DELETE(9302),GET/白名单放行, + * NONE 放行,COMPLETED 普通业务接口返回 9303,注销接口白名单放行。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class DeletionGuardInterceptorTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private AccountDeletionRepository deletionRepository; + + @MockBean private org.springframework.data.redis.core.RedisTemplate redisTemplate; + + private static final AtomicLong PHONE = new AtomicLong(932_000_000L); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + org.springframework.data.redis.core.ValueOperations ops = + mock(org.springframework.data.redis.core.ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + } + + @AfterEach + void tearDown() { + // 清理共享 H2 库(integrationdb)中的注销记录,避免残留 PENDING/COMPLETED + // 污染后续其他集成测试类(其使用固定 userId 触发注销守卫)。 + deletionRepository.deleteAll(); + userRepository.deleteAll(); + } + + private long createStudent() { + User u = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .roles(Set.of(User.Role.STUDENT)) + .role(User.Role.STUDENT) + .active(true) + .build()); + return u.getId(); + } + + private String token(long uid) { + return "Bearer " + jwtTokenProvider.generateAccessToken( + uid, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT")); + } + + private void setStatus(long uid, DeletionStatus status, LocalDateTime effectiveAt) { + deletionRepository.save(AccountDeletion.builder() + .userId(uid).status(status).effectiveAt(effectiveAt).version(0L).build()); + } + + @Test + void pending_blocksWrites_returns9302() throws Exception { + long uid = createStudent(); + setStatus(uid, DeletionStatus.PENDING, LocalDateTime.now().plusDays(30)); + + mockMvc.perform(put("/profile/me/privacy") + .header("Authorization", token(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"resumeVisibility\":\"PRIVATE\",\"contactVisible\":false}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9302)); + + mockMvc.perform(put("/profile/me") + .header("Authorization", token(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9302)); + + mockMvc.perform(post("/applications") + .header("Authorization", token(uid))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9302)); + } + + @Test + void pending_allowsGet_andDeletionWhitelist() throws Exception { + long uid = createStudent(); + setStatus(uid, DeletionStatus.PENDING, LocalDateTime.now().plusDays(30)); + + mockMvc.perform(get("/profile/me/deletion").header("Authorization", token(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PENDING")); + + // GET 默认放行 + mockMvc.perform(get("/profile/me/privacy").header("Authorization", token(uid))) + .andExpect(status().isOk()); + } + + @Test + void noneUser_isNotBlocked() throws Exception { + long uid = createStudent(); + + mockMvc.perform(put("/profile/me/privacy") + .header("Authorization", token(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"resumeVisibility\":\"PRIVATE\",\"contactVisible\":false}")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/profile/me/privacy").header("Authorization", token(uid))) + .andExpect(status().isOk()); + } + + @Test + void completed_blocksOrdinaryBusiness_with9303() throws Exception { + long uid = createStudent(); + setStatus(uid, DeletionStatus.COMPLETED, null); + + // 普通业务 GET 同样被 9303 拒绝(旧 JWT 即便未过期也失效) + mockMvc.perform(get("/profile/me/privacy").header("Authorization", token(uid))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9303)); + + mockMvc.perform(get("/profile/me/deletion").header("Authorization", token(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("COMPLETED")); + + mockMvc.perform(post("/profile/me/deletion").header("Authorization", token(uid))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9301)); + } + + @Test + void unauthenticated_isNotBlockedByInterceptor() throws Exception { + mockMvc.perform(get("/profile/me/privacy")) + .andExpect(status().isUnauthorized()); + } + + @Test + void options_isAllowed() throws Exception { + long uid = createStudent(); + setStatus(uid, DeletionStatus.PENDING, LocalDateTime.now().plusDays(30)); + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .options("/profile/me/privacy").header("Authorization", token(uid))) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackControllerTest.java index f8f997b..29cd558 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackControllerTest.java @@ -12,6 +12,7 @@ import org.springframework.data.redis.core.ValueOperations; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import com.mydreamjob.feedback.dto.CandidateFeedbackResponse; import java.util.Map; @@ -47,7 +48,7 @@ class CandidateFeedbackControllerTest { @Test void createFeedback_shouldReturn200() throws Exception { when(feedbackService.createFeedback(eq(10L), eq(APP_ID), any())) - .thenReturn(Map.of("id", 1, "rating", 4)); + .thenReturn(response(4)); String body = mapper.writeValueAsString(Map.of("rating", 4, "content", "Good")); mockMvc.perform(post("/applications/" + APP_ID + "/feedback") @@ -64,7 +65,7 @@ class CandidateFeedbackControllerTest { @Test void getFeedback_shouldReturn200() throws Exception { when(feedbackService.getFeedback(10L, APP_ID)) - .thenReturn(Map.of("id", 1, "rating", 4)); + .thenReturn(response(4)); mockMvc.perform(get("/applications/" + APP_ID + "/feedback") .header("Authorization", token())) .andExpect(status().isOk()).andExpect(jsonPath("$.data.rating").value(4)); @@ -72,7 +73,7 @@ class CandidateFeedbackControllerTest { @Test void updateFeedback_shouldReturn200() throws Exception { when(feedbackService.updateFeedback(eq(10L), eq(APP_ID), any())) - .thenReturn(Map.of("id", 1, "rating", 5)); + .thenReturn(response(5)); String body = mapper.writeValueAsString(Map.of("rating", 5, "content", "Updated")); mockMvc.perform(put("/applications/" + APP_ID + "/feedback") .header("Authorization", token()).contentType(MediaType.APPLICATION_JSON).content(body)) @@ -93,6 +94,29 @@ class CandidateFeedbackControllerTest { .andExpect(status().isBadRequest()); } + @Test void getFeedback_shouldNotExposeInternalIdentityFields() throws Exception { + when(feedbackService.getFeedback(10L, APP_ID)).thenReturn(response(4)); + mockMvc.perform(get("/applications/" + APP_ID + "/feedback") + .header("Authorization", token())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.companyUserId").doesNotExist()) + .andExpect(jsonPath("$.data.studentUserId").doesNotExist()) + .andExpect(jsonPath("$.data.passwordHash").doesNotExist()) + .andExpect(jsonPath("$.data.email").doesNotExist()) + .andExpect(jsonPath("$.data.phone").doesNotExist()) + .andExpect(jsonPath("$.data.resumeUrl").doesNotExist()); + } + + private CandidateFeedbackResponse response(int rating) { + return CandidateFeedbackResponse.builder() + .id(1L) + .applicationId(APP_ID) + .rating(rating) + .content("Good") + .tags(java.util.List.of()) + .build(); + } + @Test void createFeedback_blankContent_shouldReturn400() throws Exception { String body = mapper.writeValueAsString(Map.of("rating", 4, "content", "")); mockMvc.perform(post("/applications/" + APP_ID + "/feedback") diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java index a603f19..bca9a07 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java @@ -4,6 +4,8 @@ import com.mydreamjob.application.Application; import com.mydreamjob.application.ApplicationRepository; 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.feedback.dto.CandidateFeedbackRequest; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobService; @@ -26,10 +28,14 @@ class CandidateFeedbackServiceTest { @Mock private CandidateFeedbackRepository feedbackRepository; @Mock private ApplicationRepository applicationRepository; @Mock private JobService jobService; + @Mock private CompanyRepository companyRepository; @InjectMocks private CandidateFeedbackService feedbackService; - private static final Long COMPANY_USER_ID = 10L; - private static final Long OTHER_COMPANY_ID = 11L; + private static final Long COMPANY_USER_ID = 10L; // 企业账号 userId + private static final Long COMPANY_ID = 20L; // 企业 id(job.companyId) + private static final Long OTHER_COMPANY_USER_ID = 11L; // 其他企业账号 userId + private static final Long OTHER_COMPANY_ID = 21L; // 其他企业 id + private static final Long ADMIN_LIKE_USER_ID = 1L; // 无归属企业、且恰等于某企业 id private static final Long STUDENT_USER_ID = 100L; private static final Long OTHER_STUDENT_ID = 101L; private static final Long APPLICATION_ID = 1L; @@ -40,7 +46,12 @@ class CandidateFeedbackServiceTest { @BeforeEach void setUp() { app = Application.builder().id(APPLICATION_ID).jobId(JOB_ID).studentUserId(STUDENT_USER_ID).build(); - job = Job.builder().id(JOB_ID).companyId(COMPANY_USER_ID).title("Java").build(); + job = Job.builder().id(JOB_ID).companyId(COMPANY_ID).title("Java").build(); + } + + private void mockCompanyOwnership(Long userId, Long companyId) { + when(companyRepository.findByOwnerUserId(userId)) + .thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(userId).build())); } private CandidateFeedbackRequest buildReq() { @@ -49,21 +60,26 @@ class CandidateFeedbackServiceTest { return req; } - // ── 创建 ───────────────────────────────────────────────── - - @Test void createFeedback_shouldSucceed() { + private void stubAppAndJob() { when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); when(jobService.getJob(JOB_ID)).thenReturn(job); + } + + // ── 创建 ───────────────────────────────────────────────── + + @Test void createFeedback_companyOwnsJob_shouldSucceed() { + stubAppAndJob(); + mockCompanyOwnership(COMPANY_USER_ID, COMPANY_ID); when(feedbackRepository.existsByApplicationId(APPLICATION_ID)).thenReturn(false); when(feedbackRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); var r = feedbackService.createFeedback(COMPANY_USER_ID, APPLICATION_ID, buildReq()); - assertEquals(4, r.get("rating")); + assertEquals(4, r.getRating()); } @Test void createFeedback_duplicate_shouldReturn409() { - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); - when(jobService.getJob(JOB_ID)).thenReturn(job); + stubAppAndJob(); + mockCompanyOwnership(COMPANY_USER_ID, COMPANY_ID); when(feedbackRepository.existsByApplicationId(APPLICATION_ID)).thenReturn(true); BusinessException ex = assertThrows(BusinessException.class, @@ -71,12 +87,33 @@ class CandidateFeedbackServiceTest { assertEquals(ErrorCode.DUPLICATE_FEEDBACK.getCode(), ex.getErrorCodeInt()); } - @Test void createFeedback_wrongCompany_shouldReturn403() { + @Test void createFeedback_otherCompany_shouldReturn403() { + stubAppAndJob(); + mockCompanyOwnership(OTHER_COMPANY_USER_ID, OTHER_COMPANY_ID); + + BusinessException ex = assertThrows(BusinessException.class, + () -> feedbackService.createFeedback(OTHER_COMPANY_USER_ID, APPLICATION_ID, buildReq())); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test void createFeedback_adminUserIdEqualsCompanyId_shouldReturn403() { + // userId 恰等于 job.companyId,但该用户无归属企业 -> 必须 403(安全回归) + Job adminJob = Job.builder().id(JOB_ID).companyId(ADMIN_LIKE_USER_ID).title("Java").build(); when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); - when(jobService.getJob(JOB_ID)).thenReturn(job); + when(jobService.getJob(JOB_ID)).thenReturn(adminJob); + // 不 stub companyRepository:默认空 -> 无归属企业 + + BusinessException ex = assertThrows(BusinessException.class, + () -> feedbackService.createFeedback(ADMIN_LIKE_USER_ID, APPLICATION_ID, buildReq())); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test void createFeedback_byStudent_shouldReturn403() { + stubAppAndJob(); + // 学生无归属企业 BusinessException ex = assertThrows(BusinessException.class, - () -> feedbackService.createFeedback(OTHER_COMPANY_ID, APPLICATION_ID, buildReq())); + () -> feedbackService.createFeedback(STUDENT_USER_ID, APPLICATION_ID, buildReq())); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } @@ -90,28 +127,36 @@ class CandidateFeedbackServiceTest { // ── 查询 ───────────────────────────────────────────────── @Test void getFeedback_asCompany_shouldSucceed() { - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); - when(jobService.getJob(JOB_ID)).thenReturn(job); + stubAppAndJob(); + mockCompanyOwnership(COMPANY_USER_ID, COMPANY_ID); CandidateFeedback fb = CandidateFeedback.builder().applicationId(APPLICATION_ID).rating(4).content("Good").build(); when(feedbackRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(fb)); var r = feedbackService.getFeedback(COMPANY_USER_ID, APPLICATION_ID); - assertEquals(4, r.get("rating")); + assertEquals(4, r.getRating()); } @Test void getFeedback_asStudent_shouldSucceed() { - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); - when(jobService.getJob(JOB_ID)).thenReturn(job); + stubAppAndJob(); CandidateFeedback fb = CandidateFeedback.builder().applicationId(APPLICATION_ID).rating(4).content("Good").build(); when(feedbackRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(fb)); var r = feedbackService.getFeedback(STUDENT_USER_ID, APPLICATION_ID); - assertEquals(4, r.get("rating")); + assertEquals(4, r.getRating()); } - @Test void getFeedback_otherUser_shouldReturn403() { + @Test void getFeedback_adminUserIdEqualsCompanyId_shouldReturn403() { + Job adminJob = Job.builder().id(JOB_ID).companyId(ADMIN_LIKE_USER_ID).title("Java").build(); when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); - when(jobService.getJob(JOB_ID)).thenReturn(job); + when(jobService.getJob(JOB_ID)).thenReturn(adminJob); + + BusinessException ex = assertThrows(BusinessException.class, + () -> feedbackService.getFeedback(ADMIN_LIKE_USER_ID, APPLICATION_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test void getFeedback_otherStudent_shouldReturn403() { + stubAppAndJob(); BusinessException ex = assertThrows(BusinessException.class, () -> feedbackService.getFeedback(OTHER_STUDENT_ID, APPLICATION_ID)); @@ -121,31 +166,39 @@ class CandidateFeedbackServiceTest { // ── 修改 ───────────────────────────────────────────────── @Test void updateFeedback_shouldSucceed() { - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); - when(jobService.getJob(JOB_ID)).thenReturn(job); + stubAppAndJob(); + mockCompanyOwnership(COMPANY_USER_ID, COMPANY_ID); CandidateFeedback fb = CandidateFeedback.builder().applicationId(APPLICATION_ID).rating(4).content("Good").build(); when(feedbackRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(fb)); when(feedbackRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); CandidateFeedbackRequest req = buildReq(); req.setRating(5); var r = feedbackService.updateFeedback(COMPANY_USER_ID, APPLICATION_ID, req); - assertEquals(5, r.get("rating")); + assertEquals(5, r.getRating()); } - @Test void updateFeedback_wrongCompany_shouldReturn403() { - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); - when(jobService.getJob(JOB_ID)).thenReturn(job); + @Test void updateFeedback_otherCompany_shouldReturn403() { + stubAppAndJob(); + mockCompanyOwnership(OTHER_COMPANY_USER_ID, OTHER_COMPANY_ID); + + BusinessException ex = assertThrows(BusinessException.class, + () -> feedbackService.updateFeedback(OTHER_COMPANY_USER_ID, APPLICATION_ID, buildReq())); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test void updateFeedback_byStudent_shouldReturn403() { + stubAppAndJob(); BusinessException ex = assertThrows(BusinessException.class, - () -> feedbackService.updateFeedback(OTHER_COMPANY_ID, APPLICATION_ID, buildReq())); + () -> feedbackService.updateFeedback(STUDENT_USER_ID, APPLICATION_ID, buildReq())); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } // ── 删除 ───────────────────────────────────────────────── @Test void deleteFeedback_shouldSucceed() { - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); - when(jobService.getJob(JOB_ID)).thenReturn(job); + stubAppAndJob(); + mockCompanyOwnership(COMPANY_USER_ID, COMPANY_ID); CandidateFeedback fb = CandidateFeedback.builder().applicationId(APPLICATION_ID).build(); when(feedbackRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(fb)); @@ -153,12 +206,20 @@ class CandidateFeedbackServiceTest { verify(feedbackRepository).delete(fb); } - @Test void deleteFeedback_wrongCompany_shouldReturn403() { - when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); - when(jobService.getJob(JOB_ID)).thenReturn(job); + @Test void deleteFeedback_otherCompany_shouldReturn403() { + stubAppAndJob(); + mockCompanyOwnership(OTHER_COMPANY_USER_ID, OTHER_COMPANY_ID); + + BusinessException ex = assertThrows(BusinessException.class, + () -> feedbackService.deleteFeedback(OTHER_COMPANY_USER_ID, APPLICATION_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test void deleteFeedback_byStudent_shouldReturn403() { + stubAppAndJob(); BusinessException ex = assertThrows(BusinessException.class, - () -> feedbackService.deleteFeedback(OTHER_COMPANY_ID, APPLICATION_ID)); + () -> feedbackService.deleteFeedback(STUDENT_USER_ID, APPLICATION_ID)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/GlobalExceptionHandlerInterviewScoreTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/GlobalExceptionHandlerInterviewScoreTest.java new file mode 100644 index 0000000..73f373e --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/GlobalExceptionHandlerInterviewScoreTest.java @@ -0,0 +1,112 @@ +package com.mydreamjob.interviewscore; + +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.interviewscore.dto.InterviewScoreSaveRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 结构化面试评分错误码 HTTP 映射测试(Mock Service,仅验证 GlobalExceptionHandler 映射)。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class GlobalExceptionHandlerInterviewScoreTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + + @MockBean private InterviewScoreService interviewScoreService; + @MockBean private RedisTemplate redisTemplate; + + private String token; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + token = "Bearer " + jwtTokenProvider.generateAccessToken(10L, "1", Map.of("roles", "COMPANY")); + } + + private String body() { + return "{\"technicalScore\":4,\"communicationScore\":4,\"problemSolvingScore\":5," + + "\"roleFitScore\":5,\"professionalismScore\":4}"; + } + + @Test + void concurrentUpdate_putScore_returns409_9501() throws Exception { + doThrow(new BusinessException(ErrorCode.INTERVIEW_SCORE_CONCURRENT_UPDATE)) + .when(interviewScoreService).saveDraft(anyLong(), anyLong(), any(InterviewScoreSaveRequest.class), any()); + mockMvc.perform(put("/interviews/1/score").header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON).content(body())) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value(9501)); + } + + @Test + void submitted_putScore_returns409_9503() throws Exception { + doThrow(new BusinessException(ErrorCode.INTERVIEW_SCORE_SUBMITTED)) + .when(interviewScoreService).saveDraft(anyLong(), anyLong(), any(InterviewScoreSaveRequest.class), any()); + mockMvc.perform(put("/interviews/1/score").header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON).content(body())) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value(9503)); + } + + @Test + void notCompleted_putScore_returns409_9502() throws Exception { + doThrow(new BusinessException(ErrorCode.INTERVIEW_NOT_COMPLETED)) + .when(interviewScoreService).saveDraft(anyLong(), anyLong(), any(InterviewScoreSaveRequest.class), any()); + mockMvc.perform(put("/interviews/1/score").header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON).content(body())) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value(9502)); + } + + @Test + void submit_missingDimension_returns400_1001() throws Exception { + doThrow(new BusinessException(ErrorCode.BAD_REQUEST, "all five dimensions are required")) + .when(interviewScoreService).submitScore(anyLong(), anyLong(), any()); + mockMvc.perform(post("/interviews/1/score/submit").header("Authorization", token)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value(1001)); + } + + @Test + void getScore_notFound_returns404_9504() throws Exception { + doThrow(new BusinessException(ErrorCode.INTERVIEW_SCORE_NOT_FOUND)) + .when(interviewScoreService).getScore(anyLong(), anyLong()); + mockMvc.perform(get("/interviews/1/score").header("Authorization", token)) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(9504)); + } + + @Test + void getScore_accessDenied_returns403_8402() throws Exception { + doThrow(new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED)) + .when(interviewScoreService).getScore(anyLong(), anyLong()); + mockMvc.perform(get("/interviews/1/score").header("Authorization", token)) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(8402)); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java new file mode 100644 index 0000000..32732ce --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java @@ -0,0 +1,229 @@ +package com.mydreamjob.interviewscore; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.interview.Interview; +import com.mydreamjob.interview.InterviewRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.notification.NotificationService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 结构化面试评分控制器集成测试(真实 Service + H2)。 + * + *

覆盖 401、PUT/GET/submit/history 200、脱敏、学生写入 403、跨企业 403、 + * GET 无评分 404/9504、未完成面试 409/9502。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class InterviewScoreControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private JobRepository jobRepository; + @Autowired private ApplicationRepository applicationRepository; + @Autowired private InterviewRepository interviewRepository; + @Autowired private InterviewScoreRepository scoreRepository; + @Autowired private InterviewScoreHistoryRepository historyRepository; + + @MockBean private NotificationService notificationService; + @MockBean private RedisTemplate redisTemplate; + + private static final AtomicLong PHONE = new AtomicLong(600); + + private Long studentUserId; + private Long companyUserId; + private Long otherCompanyUserId; + private Long companyId; + private Long otherCompanyId; + private Long interviewId; + private Long pendingInterviewId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + studentUserId = createUser(User.Role.STUDENT); + companyUserId = createUser(User.Role.COMPANY); + Company company = companyRepository.save( + Company.builder().ownerUserId(companyUserId).name("评分企业").build()); + companyId = company.getId(); + Job job = jobRepository.save(Job.builder().companyId(companyId).title("Java") + .description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()); + Application app = applicationRepository.save(Application.builder() + .jobId(job.getId()).studentUserId(studentUserId).status("SUBMITTED").build()); + interviewId = interviewRepository.save(buildInterview("COMPLETED", app.getId())).getId(); + pendingInterviewId = interviewRepository.save(buildInterview("SCHEDULED", app.getId())).getId(); + + otherCompanyUserId = createUser(User.Role.COMPANY); + otherCompanyId = companyRepository.save(Company.builder() + .ownerUserId(otherCompanyUserId).name("其他企业").build()).getId(); + } + + @AfterEach + void tearDown() { + scoreRepository.deleteAll(); + historyRepository.deleteAll(); + interviewRepository.deleteAll(); + applicationRepository.deleteAll(); + jobRepository.deleteAll(); + companyRepository.deleteAll(); + userRepository.deleteAll(); + } + + private Long createUser(User.Role role) { + User u = userRepository.save(User.builder() + .phone("168" + String.format("%08d", PHONE.incrementAndGet())) + .nickname("u").role(role).roles(Set.of(role)).active(true).verified(true).build()); + return u.getId(); + } + + private Interview buildInterview(String status, Long appId) { + return Interview.builder().applicationId(appId).companyUserId(companyUserId) + .studentUserId(studentUserId).roundNo(1).title("技术面试").interviewType("ONLINE") + .startAt(LocalDateTime.now().minusDays(2)).endAt(LocalDateTime.now().minusDays(1)) + .timezone("Asia/Shanghai").meetingUrl("https://meet.example.com/1") + .status(status).version(0L).build(); + } + + private String token(Long userId, User.Role role) { + return "Bearer " + jwtTokenProvider.generateAccessToken(userId, + String.valueOf(userId), Map.of("roles", role.name())); + } + + private String bodyJson(Integer t, Integer c, Integer ps, Integer rf, Integer pf) { + return "{\"technicalScore\":" + t + ",\"communicationScore\":" + c + + ",\"problemSolvingScore\":" + ps + ",\"roleFitScore\":" + rf + + ",\"professionalismScore\":" + pf + ",\"recommendation\":\"HIRE\"}"; + } + + @Test + void put_withoutToken_returns401() throws Exception { + mockMvc.perform(put("/interviews/{id}/score", interviewId) + .contentType(MediaType.APPLICATION_JSON) + .content(bodyJson(4, 4, 5, 5, 4))) + .andExpect(status().isUnauthorized()); + } + + @Test + void fullFlow_putGetSubmitHistory_ok_andDesensitized() throws Exception { + String companyTk = token(companyUserId, User.Role.COMPANY); + mockMvc.perform(put("/interviews/{id}/score", interviewId) + .header("Authorization", companyTk) + .contentType(MediaType.APPLICATION_JSON) + .content(bodyJson(4, 4, 5, 5, 4))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("DRAFT")) + .andExpect(jsonPath("$.data.overallScore").value(4.40)); + + mockMvc.perform(post("/interviews/{id}/score/submit", interviewId) + .header("Authorization", companyTk)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("SUBMITTED")); + + String studentTk = token(studentUserId, User.Role.STUDENT); + mockMvc.perform(get("/interviews/{id}/score", interviewId) + .header("Authorization", studentTk)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.overallScore").value(4.40)) + // 脱敏:不暴露内部标识与联系方式 + .andExpect(jsonPath("$.data.companyUserId").doesNotExist()) + .andExpect(jsonPath("$.data.studentUserId").doesNotExist()) + .andExpect(jsonPath("$.data.evaluatorUserId").doesNotExist()) + .andExpect(jsonPath("$.data.phone").doesNotExist()) + .andExpect(jsonPath("$.data.email").doesNotExist()); + + mockMvc.perform(get("/interviews/{id}/score/history", interviewId) + .header("Authorization", companyTk)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(2)); + } + + @Test + void get_noScore_returns404_9504() throws Exception { + String studentTk = token(studentUserId, User.Role.STUDENT); + mockMvc.perform(get("/interviews/{id}/score", interviewId) + .header("Authorization", studentTk)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(9504)); + } + + @Test + void history_noRecords_returnsEmptyArray() throws Exception { + String companyTk = token(companyUserId, User.Role.COMPANY); + mockMvc.perform(get("/interviews/{id}/score/history", interviewId) + .header("Authorization", companyTk)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(0)); + } + + @Test + void student_writeScore_returns403_2002() throws Exception { + String studentTk = token(studentUserId, User.Role.STUDENT); + mockMvc.perform(put("/interviews/{id}/score", interviewId) + .header("Authorization", studentTk) + .contentType(MediaType.APPLICATION_JSON) + .content(bodyJson(4, 4, 5, 5, 4))) + .andExpect(status().isForbidden()); + } + + @Test + void otherCompany_returns403_8402() throws Exception { + String otherTk = token(otherCompanyUserId, User.Role.COMPANY); + mockMvc.perform(put("/interviews/{id}/score", interviewId) + .header("Authorization", otherTk) + .contentType(MediaType.APPLICATION_JSON) + .content(bodyJson(4, 4, 5, 5, 4))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(8402)); + } + + @Test + void notCompleted_interview_putScore_returns409_9502() throws Exception { + String companyTk = token(companyUserId, User.Role.COMPANY); + mockMvc.perform(put("/interviews/{id}/score", pendingInterviewId) + .header("Authorization", companyTk) + .contentType(MediaType.APPLICATION_JSON) + .content(bodyJson(4, 4, 5, 5, 4))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9502)); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScorePersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScorePersistenceIntegrationTest.java new file mode 100644 index 0000000..74485f8 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScorePersistenceIntegrationTest.java @@ -0,0 +1,272 @@ +package com.mydreamjob.interviewscore; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; +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.feedback.CandidateFeedbackRepository; +import com.mydreamjob.interview.Interview; +import com.mydreamjob.interview.InterviewRepository; +import com.mydreamjob.interviewscore.dto.InterviewScoreSaveRequest; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.notification.NotificationService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 结构化面试评分持久化/并发集成测试(独立 H2 + Flyway V31 真实表结构)。 + * + *

验证 V31 迁移可应用、interview_id 唯一、@Version 递增、历史两节点、唯一并发、 + * 提交并发幂等、通知一次,以及 candidate_feedback / candidate_dimension_scores 不被修改。 + */ +@SpringBootTest +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_interviewscore;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@ActiveProfiles("integration-test") +class InterviewScorePersistenceIntegrationTest { + + @Autowired private InterviewScoreService service; + @Autowired private InterviewScoreRepository scoreRepository; + @Autowired private InterviewScoreHistoryRepository historyRepository; + @Autowired private InterviewRepository interviewRepository; + @Autowired private ApplicationRepository applicationRepository; + @Autowired private UserRepository userRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private JobRepository jobRepository; + @Autowired private CandidateFeedbackRepository candidateFeedbackRepository; + @Autowired private CandidateDimensionScoreRepository dimensionScoreRepository; + + @MockBean private NotificationService notificationService; + @MockBean private RedisTemplate redisTemplate; + + private Long studentUserId; + private Long companyUserId; + private Long companyId; + private Long jobId; + private Long applicationId; + private Long interviewId; + + private static final java.util.concurrent.atomic.AtomicLong PHONE_SEQ = + new java.util.concurrent.atomic.AtomicLong(500); + + private final AuditContext ctx = AuditContext.builder().requestId("req").build(); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(any())).thenReturn(false); + + studentUserId = createUser(User.Role.STUDENT); + companyUserId = createUser(User.Role.COMPANY); + Company company = companyRepository.save( + Company.builder().ownerUserId(companyUserId).name("评分企业").build()); + companyId = company.getId(); + Job job = jobRepository.save(Job.builder().companyId(companyId).title("Java") + .description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()); + jobId = job.getId(); + Application app = applicationRepository.save(Application.builder() + .jobId(jobId).studentUserId(studentUserId).status("SUBMITTED").build()); + applicationId = app.getId(); + interviewId = interviewRepository.save(buildInterview("COMPLETED")).getId(); + reset(notificationService); + } + + @AfterEach + void tearDown() { + scoreRepository.deleteAll(); + historyRepository.deleteAll(); + interviewRepository.deleteAll(); + applicationRepository.deleteAll(); + jobRepository.deleteAll(); + companyRepository.deleteAll(); + userRepository.deleteAll(); + candidateFeedbackRepository.deleteAll(); + dimensionScoreRepository.deleteAll(); + } + + private Long createUser(User.Role role) { + User u = userRepository.save(User.builder() + .phone("167" + String.format("%08d", PHONE_SEQ.incrementAndGet())) + .nickname("u").role(role).roles(Set.of(role)).active(true).verified(true).build()); + return u.getId(); + } + + private Interview buildInterview(String status) { + return Interview.builder().applicationId(applicationId).companyUserId(companyUserId) + .studentUserId(studentUserId).roundNo(1).title("技术面试").interviewType("ONLINE") + .startAt(LocalDateTime.now().minusDays(2)).endAt(LocalDateTime.now().minusDays(1)) + .timezone("Asia/Shanghai").meetingUrl("https://meet.example.com/1") + .status(status).version(0L).build(); + } + + private InterviewScoreSaveRequest fullRequest() { + InterviewScoreSaveRequest r = new InterviewScoreSaveRequest(); + r.setTechnicalScore(4); + r.setCommunicationScore(4); + r.setProblemSolvingScore(5); + r.setRoleFitScore(5); + r.setProfessionalismScore(4); + r.setRecommendation("HIRE"); + r.setComment("表现不错"); + return r; + } + + private InterviewScoreSaveRequest updateRequest() { + InterviewScoreSaveRequest r = new InterviewScoreSaveRequest(); + r.setTechnicalScore(5); + r.setCommunicationScore(4); + r.setProblemSolvingScore(5); + r.setRoleFitScore(5); + r.setProfessionalismScore(4); + return r; + } + + // ── 迁移 & 主流程 ───────────────────────────────────────── + + @Test + void fullFlow_createSubmit_persistsUniqueAndOverall() { + var draft = service.saveDraft(companyUserId, interviewId, fullRequest(), ctx); + assertEquals(InterviewScore.STATUS_DRAFT, draft.getStatus()); + assertEquals(BigDecimal.valueOf(4.40).setScale(2), draft.getOverallScore()); + assertEquals(1, scoreRepository.findByInterviewId(interviewId).isPresent() ? 1 : 0); + + var submitted = service.submitScore(companyUserId, interviewId, ctx); + assertEquals(InterviewScore.STATUS_SUBMITTED, submitted.getStatus()); + assertEquals(BigDecimal.valueOf(4.40).setScale(2), submitted.getOverallScore()); + assertNotNull(submitted.getSubmittedAt()); + assertEquals(1L, submitted.getVersion()); // N+1 乐观锁递增 + + // history 两节点:NULL→DRAFT、DRAFT→SUBMITTED + assertEquals(2, historyRepository.findByInterviewIdOrderByCreatedAtAsc(interviewId).size()); + } + + @Test + void saveDraft_idempotentUpsert_noDuplicateRow() { + service.saveDraft(companyUserId, interviewId, fullRequest(), ctx); + service.saveDraft(companyUserId, interviewId, updateRequest(), ctx); + assertEquals(1, scoreRepository.findAll().size()); + var current = scoreRepository.findByInterviewId(interviewId).orElseThrow(); + assertEquals(5, current.getTechnicalScore()); + assertTrue(current.getVersion() >= 1); + } + + @Test + void submit_repeatIdempotent_noExtraHistoryOrNotification() { + service.saveDraft(companyUserId, interviewId, fullRequest(), ctx); + service.submitScore(companyUserId, interviewId, ctx); + service.submitScore(companyUserId, interviewId, ctx); + + assertEquals(2, historyRepository.findByInterviewIdOrderByCreatedAtAsc(interviewId).size()); + verify(notificationService, times(1)).publishEvent(eq(studentUserId), + eq("INTERVIEW_SCORE_SUBMITTED"), anyString(), anyString(), + eq("INTERVIEW"), eq(interviewId), eq("interview-score-submitted-" + interviewId)); + } + + @Test + void saveDraft_notCompleted_returns9502() { + Long otherInterviewId = interviewRepository.save(buildInterview("SCHEDULED")).getId(); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.saveDraft(companyUserId, otherInterviewId, fullRequest(), ctx)); + assertEquals(ErrorCode.INTERVIEW_NOT_COMPLETED.getCode(), ex.getErrorCodeInt()); + } + + @Test + void scoring_doesNotTouchFeedbackOrDimensionScores() { + long feedbackBefore = candidateFeedbackRepository.count(); + long dimBefore = dimensionScoreRepository.count(); + service.saveDraft(companyUserId, interviewId, fullRequest(), ctx); + service.submitScore(companyUserId, interviewId, ctx); + assertEquals(feedbackBefore, candidateFeedbackRepository.count()); + assertEquals(dimBefore, dimensionScoreRepository.count()); + } + + // ── 并发 ───────────────────────────────────────────────── + + @Test + void concurrentCreate_uniqueConstraint_singleRow() throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + pool.submit(() -> { await(start); runSave(fullRequest()); }); + pool.submit(() -> { await(start); runSave(updateRequest()); }); + start.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + + assertEquals(1, scoreRepository.findAll().size()); + assertTrue(scoreRepository.findByInterviewId(interviewId).isPresent()); + } + + @Test + void concurrentSubmit_onlyOneSucceeds_isIdempotent() throws Exception { + service.saveDraft(companyUserId, interviewId, fullRequest(), ctx); + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + pool.submit(() -> { await(start); runSubmit(); }); + pool.submit(() -> { await(start); runSubmit(); }); + start.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + + assertEquals(InterviewScore.STATUS_SUBMITTED, + scoreRepository.findByInterviewId(interviewId).orElseThrow().getStatus()); + // 仅一个提交历史节点 + assertEquals(1, historyRepository.findByInterviewIdOrderByCreatedAtAsc(interviewId).stream() + .filter(h -> InterviewScore.STATUS_SUBMITTED.equals(h.getToStatus())).count()); + // 通知仅发一次 + verify(notificationService, times(1)).publishEvent(eq(studentUserId), + eq("INTERVIEW_SCORE_SUBMITTED"), anyString(), anyString(), + eq("INTERVIEW"), eq(interviewId), anyString()); + } + + private void runSave(InterviewScoreSaveRequest req) { + try { + service.saveDraft(companyUserId, interviewId, req, ctx); + } catch (Exception e) { + // 并发下允许幂等返回或 9501,主流程需不抛致命异常 + assertTrue(true); + } + } + + private void runSubmit() { + try { + service.submitScore(companyUserId, interviewId, ctx); + } catch (Exception e) { + assertTrue(true); + } + } + + private void await(CountDownLatch latch) { + try { latch.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreServiceTest.java new file mode 100644 index 0000000..32e9eca --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreServiceTest.java @@ -0,0 +1,377 @@ +package com.mydreamjob.interviewscore; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.interview.Interview; +import com.mydreamjob.interview.InterviewRepository; +import com.mydreamjob.interviewscore.dto.InterviewScoreSaveRequest; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobService; +import com.mydreamjob.notification.NotificationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 结构化面试评分服务单测。 + * + *

覆盖 COMPLETED 创建草稿、非 COMPLETED=9502、草稿空维度、提交缺维度=1001、 + * overallScore 精度、recommendation 非法、提交、已提交 PUT=9503、重复提交幂等、 + * GET 无评分=9504、学生仅 SUBMITTED、跨企业 403、学生/ADMIN 写入 403、历史两节点、通知一次。 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class InterviewScoreServiceTest { + + private static final Long COMPANY_USER_ID = 10L; + private static final Long OTHER_COMPANY_USER_ID = 20L; + private static final Long COMPANY_ID = 1L; + private static final Long OTHER_COMPANY_ID = 2L; + private static final Long STUDENT_ID = 100L; + private static final Long INTERVIEW_ID = 1L; + private static final Long JOB_ID = 10L; + private static final Long APPLICATION_ID = 5L; + + @Mock private InterviewScoreRepository scoreRepository; + @Mock private InterviewScoreHistoryRepository historyRepository; + @Mock private InterviewRepository interviewRepository; + @Mock private ApplicationRepository applicationRepository; + @Mock private JobService jobService; + @Mock private CompanyRepository companyRepository; + @Mock private UserRepository userRepository; + @Mock private NotificationService notificationService; + @Mock private AuditService auditService; + + @Spy + private ObjectMapper objectMapper = new ObjectMapper(); + + @InjectMocks private InterviewScoreService service; + + private AuditContext ctx = AuditContext.builder().requestId("req-1").build(); + + @BeforeEach + void setUp() { + ctx = AuditContext.builder().requestId("req-1").build(); + } + + // ── 辅助 ──────────────────────────────────────────────── + + private void mockUser(Long userId, User.Role role) { + User u = User.builder().id(userId).phone("1").role(role).build(); + u.setRoles(new HashSet<>(Set.of(role))); + when(userRepository.findById(userId)).thenReturn(Optional.of(u)); + } + + private void mockCompany(Long userId, Long companyId) { + when(companyRepository.findByOwnerUserId(userId)) + .thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(userId).name("Corp").build())); + } + + private Interview buildInterview(String status) { + return Interview.builder().id(INTERVIEW_ID).applicationId(APPLICATION_ID) + .companyUserId(COMPANY_USER_ID).studentUserId(STUDENT_ID) + .roundNo(1).title("tech").interviewType("ONLINE").status(status).version(0L).build(); + } + + private void mockOwnership(Interview interview) { + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(interview)); + when(applicationRepository.findById(APPLICATION_ID)) + .thenReturn(Optional.of(Application.builder().id(APPLICATION_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").build())); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).companyId(COMPANY_ID).build()); + when(scoreRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + } + + private InterviewScoreSaveRequest req(Integer... scores) { + InterviewScoreSaveRequest r = new InterviewScoreSaveRequest(); + if (scores.length > 0) r.setTechnicalScore(scores[0]); + if (scores.length > 1) r.setCommunicationScore(scores[1]); + if (scores.length > 2) r.setProblemSolvingScore(scores[2]); + if (scores.length > 3) r.setRoleFitScore(scores[3]); + if (scores.length > 4) r.setProfessionalismScore(scores[4]); + return r; + } + + // ── 保存草稿 ───────────────────────────────────────────── + + @Test + void saveDraft_completedInterview_createsDraft() { + mockUser(COMPANY_USER_ID, User.Role.COMPANY); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + Interview iv = buildInterview("COMPLETED"); + mockOwnership(iv); + + var saved = service.saveDraft(COMPANY_USER_ID, INTERVIEW_ID, req(4, 4, 5, 5, 4), ctx); + + assertEquals(InterviewScore.STATUS_DRAFT, saved.getStatus()); + assertEquals(java.math.BigDecimal.valueOf(4.40).setScale(2), saved.getOverallScore()); + verify(auditService).log(eq(COMPANY_USER_ID), eq("INTERVIEW_SCORE_SAVED"), eq("INTERVIEW_SCORE"), + any(), anyString(), eq(ctx)); + verify(historyRepository).save(any()); + } + + @Test + void saveDraft_emptyDimensions_allowedOnDraft() { + mockUser(COMPANY_USER_ID, User.Role.COMPANY); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + mockOwnership(buildInterview("COMPLETED")); + + var saved = service.saveDraft(COMPANY_USER_ID, INTERVIEW_ID, new InterviewScoreSaveRequest(), ctx); + + assertEquals(InterviewScore.STATUS_DRAFT, saved.getStatus()); + assertNull(saved.getOverallScore()); + assertDoesNotThrow(() -> service.saveDraft(COMPANY_USER_ID, INTERVIEW_ID, req(4, null, null, null, null), ctx)); + } + + @Test + void saveDraft_notCompleted_returns9502() { + mockUser(COMPANY_USER_ID, User.Role.COMPANY); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + mockOwnership(buildInterview("SCHEDULED")); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.saveDraft(COMPANY_USER_ID, INTERVIEW_ID, req(4, 4, 4, 4, 4), ctx)); + assertEquals(ErrorCode.INTERVIEW_NOT_COMPLETED.getCode(), ex.getErrorCodeInt()); + } + + @Test + void saveDraft_invalidRecommendation_returns1001() { + mockUser(COMPANY_USER_ID, User.Role.COMPANY); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + mockOwnership(buildInterview("COMPLETED")); + InterviewScoreSaveRequest r = req(4, 4, 4, 4, 4); + r.setRecommendation("MAYBE"); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.saveDraft(COMPANY_USER_ID, INTERVIEW_ID, r, ctx)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void saveDraft_otherCompany_returns403() { + mockUser(OTHER_COMPANY_USER_ID, User.Role.COMPANY); + mockCompany(OTHER_COMPANY_USER_ID, OTHER_COMPANY_ID); + Interview iv = buildInterview("COMPLETED"); // companyUserId=COMPANY_USER_ID(10) + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(iv)); + when(applicationRepository.findById(APPLICATION_ID)) + .thenReturn(Optional.of(Application.builder().id(APPLICATION_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").build())); + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).companyId(OTHER_COMPANY_ID).build()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.saveDraft(OTHER_COMPANY_USER_ID, INTERVIEW_ID, req(4, 4, 4, 4, 4), ctx)); + assertEquals(ErrorCode.INTERVIEW_ACCESS_DENIED.getCode(), ex.getErrorCodeInt()); + } + + @Test + void saveDraft_student_returns403() { + mockUser(STUDENT_ID, User.Role.STUDENT); + mockOwnership(buildInterview("COMPLETED")); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.saveDraft(STUDENT_ID, INTERVIEW_ID, req(4, 4, 4, 4, 4), ctx)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 提交 ──────────────────────────────────────────────── + + private InterviewScore draftScore(Integer... scores) { + InterviewScore s = InterviewScore.builder().id(1L).interviewId(INTERVIEW_ID).applicationId(APPLICATION_ID) + .companyUserId(COMPANY_USER_ID).studentUserId(STUDENT_ID).status(InterviewScore.STATUS_DRAFT) + .version(0L).build(); + if (scores.length > 0) s.setTechnicalScore(scores[0]); + if (scores.length > 1) s.setCommunicationScore(scores[1]); + if (scores.length > 2) s.setProblemSolvingScore(scores[2]); + if (scores.length > 3) s.setRoleFitScore(scores[3]); + if (scores.length > 4) s.setProfessionalismScore(scores[4]); + return s; + } + + @Test + void submit_completeDraft_succeeds_once() { + mockUser(COMPANY_USER_ID, User.Role.COMPANY); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + mockOwnership(buildInterview("COMPLETED")); + when(scoreRepository.findByInterviewId(INTERVIEW_ID)).thenReturn(Optional.of(draftScore(4, 4, 5, 5, 4))); + + var saved = service.submitScore(COMPANY_USER_ID, INTERVIEW_ID, ctx); + + assertEquals(InterviewScore.STATUS_SUBMITTED, saved.getStatus()); + assertNotNull(saved.getSubmittedAt()); + assertEquals(java.math.BigDecimal.valueOf(4.40).setScale(2), saved.getOverallScore()); + verify(historyRepository).save(any()); + verify(notificationService).publishEvent(eq(STUDENT_ID), eq("INTERVIEW_SCORE_SUBMITTED"), + anyString(), anyString(), eq("INTERVIEW"), eq(INTERVIEW_ID), + eq("interview-score-submitted-" + INTERVIEW_ID)); + verify(auditService).log(eq(COMPANY_USER_ID), eq("INTERVIEW_SCORE_SUBMITTED"), eq("INTERVIEW_SCORE"), + any(), anyString(), eq(ctx)); + } + + @Test + void submit_missingDimension_returns1001() { + mockUser(COMPANY_USER_ID, User.Role.COMPANY); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + mockOwnership(buildInterview("COMPLETED")); + when(scoreRepository.findByInterviewId(INTERVIEW_ID)).thenReturn(Optional.of(draftScore(4, null, null, null, null))); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.submitScore(COMPANY_USER_ID, INTERVIEW_ID, ctx)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + verify(notificationService, never()).publishEvent(any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void submit_noScore_returns9504() { + mockUser(COMPANY_USER_ID, User.Role.COMPANY); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + mockOwnership(buildInterview("COMPLETED")); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.submitScore(COMPANY_USER_ID, INTERVIEW_ID, ctx)); + assertEquals(ErrorCode.INTERVIEW_SCORE_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test + void submit_repeat_isIdempotent() { + mockUser(COMPANY_USER_ID, User.Role.COMPANY); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + mockOwnership(buildInterview("COMPLETED")); + InterviewScore submitted = draftScore(4, 4, 5, 5, 4); + submitted.setStatus(InterviewScore.STATUS_SUBMITTED); + when(scoreRepository.findByInterviewId(INTERVIEW_ID)).thenReturn(Optional.of(submitted)); + + var first = service.submitScore(COMPANY_USER_ID, INTERVIEW_ID, ctx); + var second = service.submitScore(COMPANY_USER_ID, INTERVIEW_ID, ctx); + + assertEquals(InterviewScore.STATUS_SUBMITTED, first.getStatus()); + assertEquals(InterviewScore.STATUS_SUBMITTED, second.getStatus()); + verify(historyRepository, never()).save(any()); + verify(notificationService, never()).publishEvent(any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void saveDraft_afterSubmit_returns9503() { + mockUser(COMPANY_USER_ID, User.Role.COMPANY); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + mockOwnership(buildInterview("COMPLETED")); + InterviewScore submitted = draftScore(4, 4, 4, 4, 4); + submitted.setStatus(InterviewScore.STATUS_SUBMITTED); + when(scoreRepository.findByInterviewId(INTERVIEW_ID)).thenReturn(Optional.of(submitted)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.saveDraft(COMPANY_USER_ID, INTERVIEW_ID, req(5, 5, 5, 5, 5), ctx)); + assertEquals(ErrorCode.INTERVIEW_SCORE_SUBMITTED.getCode(), ex.getErrorCodeInt()); + } + + // ── 查看 ───────────────────────────────────────────────── + + @Test + void getScore_company_seesDraft() { + mockUser(COMPANY_USER_ID, User.Role.COMPANY); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + mockOwnership(buildInterview("COMPLETED")); + when(scoreRepository.findByInterviewId(INTERVIEW_ID)).thenReturn(Optional.of(draftScore(4, 4, 4, 4, 4))); + + var data = service.getScore(COMPANY_USER_ID, INTERVIEW_ID); + assertEquals(InterviewScore.STATUS_DRAFT, data.getStatus()); + } + + @Test + void getScore_noScore_returns9504() { + mockUser(COMPANY_USER_ID, User.Role.COMPANY); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + mockOwnership(buildInterview("COMPLETED")); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.getScore(COMPANY_USER_ID, INTERVIEW_ID)); + assertEquals(ErrorCode.INTERVIEW_SCORE_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test + void getScore_student_draft_returns403() { + mockUser(STUDENT_ID, User.Role.STUDENT); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("COMPLETED"))); + when(scoreRepository.findByInterviewId(INTERVIEW_ID)).thenReturn(Optional.of(draftScore(4, 4, 4, 4, 4))); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.getScore(STUDENT_ID, INTERVIEW_ID)); + assertEquals(ErrorCode.INTERVIEW_ACCESS_DENIED.getCode(), ex.getErrorCodeInt()); + } + + @Test + void getScore_student_submitted_ok() { + mockUser(STUDENT_ID, User.Role.STUDENT); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("COMPLETED"))); + InterviewScore submitted = draftScore(4, 4, 5, 5, 4); + submitted.setStatus(InterviewScore.STATUS_SUBMITTED); + when(scoreRepository.findByInterviewId(INTERVIEW_ID)).thenReturn(Optional.of(submitted)); + + var data = service.getScore(STUDENT_ID, INTERVIEW_ID); + assertEquals(InterviewScore.STATUS_SUBMITTED, data.getStatus()); + verify(userRepository).findById(STUDENT_ID); + } + + @Test + void getScore_admin_returns403() { + mockUser(999L, User.Role.ADMIN); + when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("COMPLETED"))); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.getScore(999L, INTERVIEW_ID)); + assertEquals(ErrorCode.INTERVIEW_ACCESS_DENIED.getCode(), ex.getErrorCodeInt()); + } + + // ── 历史 ───────────────────────────────────────────────── + + @Test + void getHistory_noRecords_returnsEmptyList() { + mockUser(COMPANY_USER_ID, User.Role.COMPANY); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + mockOwnership(buildInterview("COMPLETED")); + when(historyRepository.findByInterviewIdOrderByCreatedAtAsc(INTERVIEW_ID)).thenReturn(Collections.emptyList()); + + assertTrue(service.getHistory(COMPANY_USER_ID, INTERVIEW_ID).isEmpty()); + } + + @Test + void getHistory_company_seesAll_doesNotExposeActorUser() { + mockUser(COMPANY_USER_ID, User.Role.COMPANY); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + mockOwnership(buildInterview("COMPLETED")); + InterviewScoreHistory h = InterviewScoreHistory.builder().id(1L).scoreId(1L).interviewId(INTERVIEW_ID) + .fromStatus(null).toStatus("DRAFT").actorUserId(COMPANY_USER_ID).actorRole("COMPANY") + .snapshotText("{\"overallScore\":4.40}").build(); + when(historyRepository.findByInterviewIdOrderByCreatedAtAsc(INTERVIEW_ID)).thenReturn(List.of(h)); + + var items = service.getHistory(COMPANY_USER_ID, INTERVIEW_ID); + assertEquals(1, items.size()); + // DTO 结构性脱敏:无 actorUserId 字段 + assertEquals("COMPANY", items.get(0).getActorRole()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobFavoriteServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobFavoriteServiceTest.java index 6ac3fa8..42d3e49 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobFavoriteServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobFavoriteServiceTest.java @@ -153,6 +153,14 @@ class JobFavoriteServiceTest { assertNotNull(result.getContent().get(0).get("favoritedAt")); } + @Test + void listFavorites_sizeZero_shouldReturn400() { + mockStudentRole(STUDENT_ID); + BusinessException ex = assertThrows(BusinessException.class, + () -> favoriteService.listFavorites(STUDENT_ID, 0, 0)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + // ── 角色越权 ───────────────────────────────────────────── @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java index 0226c7d..e29fcd2 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/match/MatchServiceTest.java @@ -1,6 +1,8 @@ package com.mydreamjob.match; 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.job.Job; @@ -16,8 +18,10 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.HashSet; import java.util.Map; import java.util.Optional; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -29,9 +33,12 @@ class MatchServiceTest { @Mock private StudentPreferenceRepository preferenceRepository; @Mock private StudentProfileRepository profileRepository; @Mock private AuditService auditService; + @Mock private UserRepository userRepository; @InjectMocks private MatchService matchService; private static final Long STUDENT_ID = 100L; + private static final Long COMPANY_ID = 200L; + private static final Long ADMIN_ID = 300L; private static final Long JOB_ID = 10L; private Job publishedJob; @@ -43,6 +50,12 @@ class MatchServiceTest { .salaryMax(20000).status("PUBLISHED").build(); } + private void mockRole(Long userId, User.Role role) { + User u = User.builder().id(userId).phone("1").role(role).build(); + u.setRoles(new HashSet<>(Set.of(role))); + when(userRepository.findById(userId)).thenReturn(Optional.of(u)); + } + private void mockPref() { StudentPreference p = StudentPreference.builder().studentUserId(STUDENT_ID) .preferredCities("[\"杭州\"]").employmentTypes("[\"FULL_TIME\"]") @@ -56,9 +69,26 @@ class MatchServiceTest { StudentProfile.builder().userId(STUDENT_ID).build())); } + // ── 角色校验 ───────────────────────────────────────────── + + @Test void diagnose_asCompany_shouldReturn403() { + mockRole(COMPANY_ID, User.Role.COMPANY); + BusinessException ex = assertThrows(BusinessException.class, + () -> matchService.diagnose(COMPANY_ID, JOB_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test void diagnose_asAdmin_shouldReturn403() { + mockRole(ADMIN_ID, User.Role.ADMIN); + BusinessException ex = assertThrows(BusinessException.class, + () -> matchService.diagnose(ADMIN_ID, JOB_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + // ── 完全匹配 ───────────────────────────────────────────── @Test void diagnose_fullMatch_shouldBeEligible() { + mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); mockPref(); mockProfile(); @@ -73,6 +103,7 @@ class MatchServiceTest { // ── 部分匹配 ───────────────────────────────────────────── @Test void diagnose_partialMatch_shouldBeEligibleWithGaps() { + mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); StudentPreference p = StudentPreference.builder().studentUserId(STUDENT_ID) .preferredCities("[\"上海\"]").employmentTypes("[\"PART_TIME\"]") @@ -89,6 +120,7 @@ class MatchServiceTest { // ── 硬性条件不满足 ─────────────────────────────────────── @Test void diagnose_hardGap_salaryTooHigh_shouldBeIneligible() { + mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); StudentPreference p = StudentPreference.builder().studentUserId(STUDENT_ID) .preferredCities("[\"杭州\"]").employmentTypes("[\"FULL_TIME\"]") @@ -106,6 +138,7 @@ class MatchServiceTest { // ── 空档案 ─────────────────────────────────────────────── @Test void diagnose_emptyProfile_shouldBeIneligibleWithZero() { + mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.empty()); @@ -120,6 +153,7 @@ class MatchServiceTest { // ── 阈值边界 ───────────────────────────────────────────── @Test void diagnose_salaryBoundary_equal_shouldBeEligible() { + mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); // salaryMax=20000 StudentPreference p = StudentPreference.builder().studentUserId(STUDENT_ID) .preferredCities("[\"杭州\"]").employmentTypes("[\"FULL_TIME\"]") @@ -136,6 +170,7 @@ class MatchServiceTest { // ── 岗位不存在 ─────────────────────────────────────────── @Test void diagnose_jobNotFound_shouldReturn404() { + mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(999L)).thenThrow(new BusinessException(ErrorCode.JOB_NOT_FOUND)); assertThrows(BusinessException.class, () -> matchService.diagnose(STUDENT_ID, 999L)); @@ -144,6 +179,7 @@ class MatchServiceTest { // ── 不泄露敏感数据 ─────────────────────────────────────── @Test void diagnose_shouldNotIncludeSensitiveData() { + mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); mockPref(); mockProfile(); @@ -157,6 +193,7 @@ class MatchServiceTest { // ── 审计记录 ───────────────────────────────────────────── @Test void diagnose_shouldLogAudit() { + mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); mockPref(); mockProfile(); @@ -166,6 +203,7 @@ class MatchServiceTest { } @Test void diagnose_withContext_shouldPersistContext() { + mockRole(STUDENT_ID, User.Role.STUDENT); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); mockPref(); mockProfile(); com.mydreamjob.audit.AuditContext ctx = com.mydreamjob.audit.AuditContext.builder() diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingBlockIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingBlockIntegrationTest.java new file mode 100644 index 0000000..6e0f8fb --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingBlockIntegrationTest.java @@ -0,0 +1,218 @@ +package com.mydreamjob.messaging; + +import com.mydreamjob.audit.AuditLogRepository; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.messaging.dto.CreateConversationRequest; +import com.mydreamjob.messaging.dto.SendMessageRequest; +import com.mydreamjob.safety.BlockRelation; +import com.mydreamjob.safety.BlockRelationRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * V33 屏蔽对站内信影响的集成测试(真实 MessagingService + H2)。 + * + *

覆盖:屏蔽后禁止新建 DIRECT 会话(双向)、屏蔽后禁止发送消息、历史消息仍可读取、 + * 屏蔽期间失败消息不补发。对外统一 403/2002,不泄露屏蔽方向。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class MessagingBlockIntegrationTest { + + @Autowired private MessagingService messagingService; + @Autowired private UserRepository userRepository; + @Autowired private ConversationRepository conversationRepository; + @Autowired private ConversationMemberRepository memberRepository; + @Autowired private MessageRepository messageRepository; + @Autowired private BlockRelationRepository blockRelationRepository; + @Autowired private AuditLogRepository auditLogRepository; + + @MockBean private RedisTemplate redisTemplate; + + private static final AtomicLong PHONE = new AtomicLong(2800); + + private Long a; + private Long b; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + a = createUser("A"); + b = createUser("B"); + } + + @AfterEach + void tearDown() { + messageRepository.deleteAll(); + memberRepository.deleteAll(); + blockRelationRepository.deleteAll(); + conversationsRoomCleanup(); + } + + /** AUDIT 安全审计由独立事务写入共享 audit_logs;先删会话成员/会话,最后清理本测试的审计行。 */ + private void conversationsRoomCleanup() { + conversationRepository.deleteAll(); + userRepository.deleteAll(); + auditLogRepository.deleteAll(); + } + + private Long createUser(String tag) { + return userRepository.save(User.builder() + .phone("139" + String.format("%08d", PHONE.incrementAndGet())) + .nickname(tag).role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)) + .active(true).verified(true).build()).getId(); + } + + private void block(Long blocker, Long blocked) { + blockRelationRepository.save(BlockRelation.builder() + .blockerUserId(blocker).blockedUserId(blocked).build()); + } + + private void unblock(Long blocker, Long blocked) { + blockRelationRepository.findByBlockerUserIdAndBlockedUserId(blocker, blocked) + .ifPresent(blockRelationRepository::delete); + } + + private Long createConversation(Long u1, Long u2) { + Long convId = conversationRepository.save( + Conversation.builder().createdBy(u1).type("DIRECT").build()).getId(); + memberRepository.save(ConversationMember.builder().conversationId(convId).userId(u1).build()); + memberRepository.save(ConversationMember.builder().conversationId(convId).userId(u2).build()); + return convId; + } + + private void send(Long sender, Long convId, String content) { + SendMessageRequest msg = new SendMessageRequest(); + msg.setConversationId(convId); + msg.setContent(content); + messagingService.sendMessage(sender, msg); + } + + // 屏蔽后禁止新建 DIRECT 会话(双向) + @Test + void blocked_preventsNewDirectConversation_bothDirections() { + block(a, b); + CreateConversationRequest fromA = new CreateConversationRequest(); + fromA.setParticipantUserId(b); + CreateConversationRequest fromB = new CreateConversationRequest(); + fromB.setParticipantUserId(a); + Assertions.assertThrows(BusinessException.class, + () -> messagingService.createOrGetDirectConversation(a, fromA)); + Assertions.assertThrows(BusinessException.class, + () -> messagingService.createOrGetDirectConversation(b, fromB)); + } + + // 屏蔽后禁止发送消息(双方) + @Test + void blocked_preventsSendMessage_bothSides() { + Long convId = createConversation(a, b); + block(a, b); + Assertions.assertThrows(BusinessException.class, () -> send(a, convId, "hi")); + Assertions.assertThrows(BusinessException.class, () -> send(b, convId, "hey")); + } + + // 历史消息仍可读取(listConversations / listMessages / getConversation) + @Test + void historyStillReadable_afterBlock() { + Long convId = createConversation(a, b); + send(a, convId, "历史消息"); + block(a, b); + + List> convs = messagingService.listConversations(b); + Assertions.assertTrue(convs.stream().anyMatch(c -> + ((Number) c.get("id")).longValue() == convId), "被屏蔽后仍应能看到既有会话"); + + Page> msgs = messagingService.listMessages(convId, b, 0, 20); + Assertions.assertEquals(1, msgs.getTotalElements()); + Assertions.assertEquals("历史消息", msgs.getContent().get(0).get("content")); + + Map single = messagingService.getConversation(convId, b); + Assertions.assertEquals(convId, ((Number) single.get("id")).longValue()); + } + + // 屏蔽期间失败消息不补发:失败消息不落库,解除屏蔽后不会自动重发 + @Test + void blockedMessage_notPersisted_notReplayedAfterUnblock() { + Long convId = createConversation(a, b); + block(a, b); + + Assertions.assertThrows(BusinessException.class, () -> send(a, convId, "屏蔽期间的消息")); + + // 失败消息未落库 + Page> before = messagingService.listMessages(convId, a, 0, 20); + Assertions.assertEquals(0, before.getTotalElements()); + + // 即使是已存在的会话,解除屏蔽后需手动重新发送;历史不会自动补发 + unblock(a, b); + send(a, convId, "解除屏蔽后发送"); + Page> after = messagingService.listMessages(convId, a, 0, 20); + Assertions.assertEquals(1, after.getTotalElements()); + Assertions.assertEquals("解除屏蔽后发送", after.getContent().get(0).get("content")); + } + + // 未屏蔽时不受影响(回归) + @Test + void noBlock_normalFlowWorks() { + Long convId = createConversation(a, b); + send(a, convId, "正常消息"); + CreateConversationRequest req = new CreateConversationRequest(); + req.setParticipantUserId(a); + Assertions.assertNotNull(messagingService.createOrGetDirectConversation(b, req)); + } + + // 屏蔽拒发:即使外层请求抛 403,拒绝型安全审计仍应在独立事务落库 + @Test + void blocked_send_shouldPersistSecurityAudit_evenOn403() { + Long convId = createConversation(a, b); + block(a, b); + Assertions.assertThrows(BusinessException.class, () -> send(a, convId, "blocked")); + + long cnt = auditLogRepository.findAll().stream() + .filter(al -> "BLOCKED_SEND_PROHIBITED".equals(al.getAction()) + && a.equals(al.getUserId()) && b.equals(al.getEntityId())) + .count(); + Assertions.assertTrue(cnt >= 1, "屏蔽拒发后 BLOCKED_SEND_PROHIBITED 审计应落库,实际=" + cnt); + } + + // 屏蔽建会话:203 前同样落库拒绝型安全审计 + @Test + void blocked_createConversation_shouldPersistSecurityAudit_evenOn403() { + block(b, a); // b 屏蔽了 a,a 试图新建 + CreateConversationRequest req = new CreateConversationRequest(); + req.setParticipantUserId(b); + Assertions.assertThrows(BusinessException.class, + () -> messagingService.createOrGetDirectConversation(a, req)); + + long cnt = auditLogRepository.findAll().stream() + .filter(al -> "BLOCKED_SEND_PROHIBITED".equals(al.getAction()) + && a.equals(al.getUserId()) && b.equals(al.getEntityId())) + .count(); + Assertions.assertTrue(cnt >= 1, "屏蔽建会话后 BLOCKED_SEND_PROHIBITED 审计应落库,实际=" + cnt); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingControllerTest.java index bab0d50..fdb751a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingControllerTest.java @@ -1,5 +1,7 @@ package com.mydreamjob.messaging; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.messaging.dto.CreateConversationRequest; import com.mydreamjob.messaging.dto.SendMessageRequest; import com.fasterxml.jackson.databind.ObjectMapper; @@ -96,4 +98,22 @@ class MessagingControllerTest { mockMvc.perform(post("/conversations").contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isUnauthorized()); } + + // ── 会话不存在 → 404/8305;非成员 → 403/2002 ──────────────── + + @Test void getConversation_notFound_shouldReturn404() throws Exception { + when(messagingService.getConversation(1L, 100L)) + .thenThrow(new BusinessException(ErrorCode.CONVERSATION_NOT_FOUND)); + mockMvc.perform(get("/conversations/1").header("Authorization", token())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(8305)); + } + + @Test void getConversation_asNonMember_shouldReturn403() throws Exception { + when(messagingService.getConversation(1L, 100L)) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + mockMvc.perform(get("/conversations/1").header("Authorization", token())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java index 49682f3..6f37f1f 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java @@ -1,5 +1,7 @@ package com.mydreamjob.messaging; +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.audit.SecurityAuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecordRepository; @@ -9,6 +11,7 @@ import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.messaging.dto.CreateConversationRequest; import com.mydreamjob.messaging.dto.SendMessageRequest; +import com.mydreamjob.safety.BlockRelationRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -35,6 +38,8 @@ class MessagingServiceTest { @Mock private UserRepository userRepository; @Mock private CompanyRepository companyRepository; @Mock private ApplicationMatchRecordRepository applicationMatchRecordRepository; + @Mock private BlockRelationRepository blockRelationRepository; + @Mock private SecurityAuditService securityAuditService; @InjectMocks private MessagingService messagingService; private static final Long USER_A = 100L; @@ -46,6 +51,14 @@ class MessagingServiceTest { void setUp() { } + private Conversation conv(Long id) { + return Conversation.builder().id(id).createdBy(USER_A).type("DIRECT").build(); + } + + private void stubConversationExists() { + when(conversationRepository.findById(CONV_ID)).thenReturn(Optional.of(conv(CONV_ID))); + } + // ── 创建会话 ───────────────────────────────────────────── @Test void createConversation_withNewUser_shouldCreate() { @@ -157,6 +170,7 @@ class MessagingServiceTest { } @Test void sendMessage_asNonMember_shouldReturn403() { + stubConversationExists(); when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_C)).thenReturn(false); SendMessageRequest req = new SendMessageRequest(); req.setConversationId(CONV_ID); req.setContent("Hack"); @@ -167,18 +181,76 @@ class MessagingServiceTest { } @Test void sendMessage_tooLong_shouldReturn400() { + stubConversationExists(); when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_A)).thenReturn(true); SendMessageRequest req = new SendMessageRequest(); req.setConversationId(CONV_ID); req.setContent("x".repeat(2001)); - assertThrows(BusinessException.class, + BusinessException ex = assertThrows(BusinessException.class, () -> messagingService.sendMessage(USER_A, req)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + // ── V33 屏蔽拒发:拒绝型安全审计仍需落库(即使外层返回 403) ──── + + @Test void sendMessage_blocked_shouldLogSecurityAudit() { + stubConversationExists(); + when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_A)).thenReturn(true); + // 会话里存在另一成员 B,且 A 屏蔽了 B + when(memberRepository.findByConversationId(CONV_ID)) + .thenReturn(List.of(ConversationMember.builder().conversationId(CONV_ID).userId(USER_B).build())); + when(blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(USER_A, USER_B)).thenReturn(true); + + SendMessageRequest req = new SendMessageRequest(); + req.setConversationId(CONV_ID); + req.setContent("blocked"); + + BusinessException ex = assertThrows(BusinessException.class, + () -> messagingService.sendMessage(USER_A, req)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + // 即使请求被拒,拒绝型安全审计也必须被记录 + verify(securityAuditService).logRejected(eq(USER_A), eq("BLOCKED_SEND_PROHIBITED"), + eq("USER"), eq(USER_B), anyString(), any(AuditContext.class)); + } + + @Test void createConversation_blocked_shouldLogSecurityAudit() { + CreateConversationRequest req = new CreateConversationRequest(); + req.setParticipantUserId(USER_B); + when(userRepository.existsById(USER_B)).thenReturn(true); + when(memberRepository.findByUserId(USER_A)).thenReturn(List.of()); + when(blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(USER_A, USER_B)).thenReturn(true); + + BusinessException ex = assertThrows(BusinessException.class, + () -> messagingService.createOrGetDirectConversation(USER_A, req)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + // 即使请求被拒,拒绝型安全审计也必须被记录 + verify(securityAuditService).logRejected(eq(USER_A), eq("BLOCKED_SEND_PROHIBITED"), + eq("USER"), eq(USER_B), anyString(), any(AuditContext.class)); + } + + @Test void sendMessage_unblocked_shouldNotLogSecurityAudit() { + stubConversationExists(); + when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_A)).thenReturn(true); + when(memberRepository.findByConversationId(CONV_ID)) + .thenReturn(List.of(ConversationMember.builder().conversationId(CONV_ID).userId(USER_B).build())); + when(blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(USER_A, USER_B)).thenReturn(false); + when(blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(USER_B, USER_A)).thenReturn(false); + when(messageRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(conversationRepository.findById(CONV_ID)).thenReturn(Optional.of(conv(CONV_ID))); + when(conversationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + SendMessageRequest req = new SendMessageRequest(); + req.setConversationId(CONV_ID); + req.setContent("ok"); + assertDoesNotThrow(() -> messagingService.sendMessage(USER_A, req)); + verify(securityAuditService, never()).logRejected(any(), any(), any(), any(), any(), any()); } // ── 消息列表 ───────────────────────────────────────────── @Test void listMessages_asMember_shouldSucceed() { + stubConversationExists(); when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_A)).thenReturn(true); Message msg = Message.builder().id(1L).conversationId(CONV_ID).senderUserId(USER_A).content("Hi").build(); when(messageRepository.findByConversationIdOrderByCreatedAtDesc(eq(CONV_ID), any())) @@ -191,14 +263,17 @@ class MessagingServiceTest { } @Test void listMessages_asNonMember_shouldReturn403() { + stubConversationExists(); when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_C)).thenReturn(false); - assertThrows(BusinessException.class, + BusinessException ex = assertThrows(BusinessException.class, () -> messagingService.listMessages(CONV_ID, USER_C, 0, 20)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } // ── 标记已读 ───────────────────────────────────────────── @Test void markRead_shouldCreateReadRecords() { + stubConversationExists(); when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_A)).thenReturn(true); Message msg = Message.builder().id(1L).conversationId(CONV_ID).senderUserId(USER_B).content("Hi").build(); when(messageRepository.findByConversationIdOrderByCreatedAtDesc(eq(CONV_ID), any())) @@ -211,8 +286,61 @@ class MessagingServiceTest { } @Test void markRead_asNonMember_shouldReturn403() { + stubConversationExists(); when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_C)).thenReturn(false); - assertThrows(BusinessException.class, + BusinessException ex = assertThrows(BusinessException.class, () -> messagingService.markRead(CONV_ID, USER_C)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 会话存在性检查(不存在 → CONVERSATION_NOT_FOUND) ──────── + + @Test void getConversation_conversationNotFound_shouldReturn404() { + when(conversationRepository.findById(999L)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> messagingService.getConversation(999L, USER_A)); + assertEquals(ErrorCode.CONVERSATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test void getConversation_asMember_shouldSucceed() { + stubConversationExists(); + when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_A)).thenReturn(true); + when(messageRepository.findByConversationIdOrderByCreatedAtDesc(eq(CONV_ID), any())) + .thenReturn(new PageImpl<>(List.of())); + + var v = messagingService.getConversation(CONV_ID, USER_A); + assertEquals(CONV_ID, v.get("id")); + assertEquals(0L, v.get("unreadCount")); + } + + @Test void getConversation_asNonMember_shouldReturn403() { + stubConversationExists(); + when(memberRepository.existsByConversationIdAndUserId(CONV_ID, USER_C)).thenReturn(false); + BusinessException ex = assertThrows(BusinessException.class, + () -> messagingService.getConversation(CONV_ID, USER_C)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test void listMessages_conversationNotFound_shouldReturn404() { + when(conversationRepository.findById(999L)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> messagingService.listMessages(999L, USER_A, 0, 20)); + assertEquals(ErrorCode.CONVERSATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test void sendMessage_conversationNotFound_shouldReturn404() { + when(conversationRepository.findById(999L)).thenReturn(Optional.empty()); + SendMessageRequest req = new SendMessageRequest(); + req.setConversationId(999L); req.setContent("Hi"); + BusinessException ex = assertThrows(BusinessException.class, + () -> messagingService.sendMessage(USER_A, req)); + assertEquals(ErrorCode.CONVERSATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test void markRead_conversationNotFound_shouldReturn404() { + when(conversationRepository.findById(999L)).thenReturn(Optional.empty()); + BusinessException ex = assertThrows(BusinessException.class, + () -> messagingService.markRead(999L, USER_A)); + assertEquals(ErrorCode.CONVERSATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferControllerTest.java index a39c1cf..c7a6030 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferControllerTest.java @@ -303,4 +303,168 @@ class OfferControllerTest { json.getBytes(StandardCharsets.UTF_8)); } } + + // ── Offer 进阶接口 ──────────────────────────────────────── + + @Test + void openApi_shouldContainNewOfferEndpoints() throws Exception { + String json = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode root = mapper.readTree(json); + JsonNode paths = root.path("paths"); + assertThat(paths.has("/offers/{id}/counter-offers")).isTrue(); + assertThat(paths.has("/offers/{id}/counter-offers/{coId}/accept")).isTrue(); + assertThat(paths.has("/offers/{id}/counter-offers/{coId}/reject")).isTrue(); + assertThat(paths.has("/offers/compare")).isTrue(); + assertThat(paths.has("/offers/statistics")).isTrue(); + } + + @Test + void createCounterOffer_shouldReturn200() throws Exception { + Map coView = Map.of("id", 1L, "offerId", OFFER_ID, "status", "PENDING"); + when(offerService.toCounterOfferView(any())).thenReturn(coView); + when(offerService.createCounterOffer(eq(STUDENT_ID), eq(OFFER_ID), any(), any())) + .thenReturn(OfferCounterOffer.builder().id(1L).build()); + mockMvc.perform(post("/offers/" + OFFER_ID + "/counter-offers") + .header("Authorization", studentToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"proposedSalaryMin\":15000,\"proposedSalaryMax\":25000,\"reason\":\"期望薪资更高\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.status").value("PENDING")); + } + + @Test + void createCounterOffer_notNegotiable_shouldReturn400() throws Exception { + when(offerService.createCounterOffer(eq(STUDENT_ID), eq(OFFER_ID), any(), any())) + .thenThrow(new BusinessException(ErrorCode.OFFER_NOT_NEGOTIABLE)); + mockMvc.perform(post("/offers/" + OFFER_ID + "/counter-offers") + .header("Authorization", studentToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"proposedSalaryMin\":15000,\"proposedSalaryMax\":25000}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.OFFER_NOT_NEGOTIABLE.getCode())); + } + + @Test + void listCounterOffers_shouldReturn200() throws Exception { + when(offerService.getCounterOffers(OFFER_ID, STUDENT_ID)).thenReturn(List.of()); + mockMvc.perform(get("/offers/" + OFFER_ID + "/counter-offers") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + @Test + void acceptCounterOffer_shouldReturn200() throws Exception { + Map coView = Map.of("id", 1L, "status", "ACCEPTED"); + when(offerService.toCounterOfferView(any())).thenReturn(coView); + when(offerService.acceptCounterOffer(eq(COMPANY_USER_ID), eq(OFFER_ID), eq(1L), any())) + .thenReturn(OfferCounterOffer.builder().id(1L).build()); + mockMvc.perform(patch("/offers/" + OFFER_ID + "/counter-offers/1/accept") + .header("Authorization", companyToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.status").value("ACCEPTED")); + } + + @Test + void acceptCounterOffer_notPending_shouldReturn409() throws Exception { + when(offerService.acceptCounterOffer(eq(COMPANY_USER_ID), eq(OFFER_ID), eq(1L), any())) + .thenThrow(new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING)); + mockMvc.perform(patch("/offers/" + OFFER_ID + "/counter-offers/1/accept") + .header("Authorization", companyToken())) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING.getCode())); + } + + @Test + void rejectCounterOffer_shouldReturn200() throws Exception { + Map coView = Map.of("id", 1L, "status", "REJECTED"); + when(offerService.toCounterOfferView(any())).thenReturn(coView); + when(offerService.rejectCounterOffer(eq(COMPANY_USER_ID), eq(OFFER_ID), eq(1L), any())) + .thenReturn(OfferCounterOffer.builder().id(1L).build()); + mockMvc.perform(patch("/offers/" + OFFER_ID + "/counter-offers/1/reject") + .header("Authorization", companyToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.status").value("REJECTED")); + } + + @Test + void compareOffers_shouldReturn200() throws Exception { + Map v = new java.util.HashMap<>(offerView()); + v.put("companyName", "测试公司"); + when(offerService.compareOffers(eq(STUDENT_ID), eq(List.of(1L, 2L)))) + .thenReturn(List.of(v, v)); + mockMvc.perform(get("/offers/compare?ids=1,2") + .header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data[0].companyName").value("测试公司")); + } + + @Test + void compareOffers_tooMany_shouldReturn400() throws Exception { + when(offerService.compareOffers(eq(STUDENT_ID), any())) + .thenThrow(new BusinessException(ErrorCode.OFFER_COMPARE_TOO_MANY)); + mockMvc.perform(get("/offers/compare?ids=1") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.OFFER_COMPARE_TOO_MANY.getCode())); + } + + @Test + void compareOffers_nonNumeric_shouldReturn400() throws Exception { + mockMvc.perform(get("/offers/compare?ids=abc") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode())); + } + + @Test + void compareOffers_emptyValue_shouldReturn400() throws Exception { + mockMvc.perform(get("/offers/compare?ids=1,,2") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode())); + } + + @Test + void compareOffers_blank_shouldReturn400() throws Exception { + mockMvc.perform(get("/offers/compare?ids=") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode())); + } + + @Test + void compareOffers_negative_shouldReturn400() throws Exception { + mockMvc.perform(get("/offers/compare?ids=1,-2") + .header("Authorization", studentToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode())); + } + + @Test + void statistics_shouldReturn200() throws Exception { + when(offerService.getStatistics(COMPANY_USER_ID, 6)) + .thenReturn(Map.of("totalOffers", 10, "acceptanceRate", 0.6)); + mockMvc.perform(get("/offers/statistics") + .header("Authorization", companyToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.totalOffers").value(10)); + } + + @Test + void statistics_withMonths_shouldReturn200() throws Exception { + when(offerService.getStatistics(COMPANY_USER_ID, 12)) + .thenReturn(Map.of("totalOffers", 5, "periodMonths", 12)); + mockMvc.perform(get("/offers/statistics?months=12") + .header("Authorization", companyToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.periodMonths").value(12)); + } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryWorkerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryWorkerTest.java index 10f07e1..06ea97c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryWorkerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryWorkerTest.java @@ -24,6 +24,7 @@ class OfferExpiryWorkerTest { @Mock private OfferRepository offerRepository; @Mock private OfferStatusHistoryRepository historyRepository; @Mock private OfferReminderRepository reminderRepository; + @Mock private OfferCounterOfferRepository counterOfferRepository; @Mock private CompanyRepository companyRepository; @Mock private NotificationService notificationService; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java index 483e3cd..14d5421 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java @@ -13,6 +13,7 @@ import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobService; import com.mydreamjob.notification.NotificationService; +import com.mydreamjob.offer.dto.CounterOfferCreateRequest; import com.mydreamjob.offer.dto.OfferCreateRequest; import com.mydreamjob.offer.dto.OfferStatusRequest; import com.mydreamjob.offer.dto.OfferUpdateRequest; @@ -25,8 +26,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.PageImpl; import java.time.LocalDateTime; +import java.util.Arrays; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; @@ -41,6 +44,7 @@ class OfferServiceTest { @Mock private OfferRepository offerRepository; @Mock private OfferStatusHistoryRepository historyRepository; @Mock private OfferReminderRepository reminderRepository; + @Mock private OfferCounterOfferRepository counterOfferRepository; @Mock private ApplicationRepository applicationRepository; @Mock private JobService jobService; @Mock private CompanyRepository companyRepository; @@ -564,4 +568,287 @@ class OfferServiceTest { () -> offerService.remind(STUDENT_ID, OFFER_ID, ctx)); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); } + + // ── 还价 ────────────────────────────────────────────────── + + private CounterOfferCreateRequest buildCounterOfferReq() { + CounterOfferCreateRequest req = new CounterOfferCreateRequest(); + req.setProposedSalaryMin(15000); + req.setProposedSalaryMax(25000); + req.setReason("期望薪资更高"); + return req; + } + + private OfferCounterOffer buildCounterOffer(String status) { + return OfferCounterOffer.builder().id(1L).offerId(OFFER_ID) + .studentId(STUDENT_ID).proposedSalaryMin(15000).proposedSalaryMax(25000) + .status(status).activeKey("PENDING".equals(status) ? OfferCounterOffer.ACTIVE_KEY : null) + .version(0L).build(); + } + + @Test + void createCounterOffer_sent_shouldAutoNegotiating() { + mockStudentUser(STUDENT_ID); + mockCompanyById(COMPANY_ID, COMPANY_USER_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("SENT"))); + when(counterOfferRepository.findByOfferIdAndActiveKeyNotNull(OFFER_ID)).thenReturn(Optional.empty()); + when(offerRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(counterOfferRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + OfferCounterOffer co = offerService.createCounterOffer(STUDENT_ID, OFFER_ID, buildCounterOfferReq(), ctx); + assertEquals(OfferCounterOffer.STATUS_PENDING, co.getStatus()); + assertEquals(OfferCounterOffer.ACTIVE_KEY, co.getActiveKey()); + verify(counterOfferRepository).save(any()); + } + + @Test + void createCounterOffer_negotiating_shouldSucceed() { + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("NEGOTIATING"))); + when(counterOfferRepository.findByOfferIdAndActiveKeyNotNull(OFFER_ID)).thenReturn(Optional.empty()); + when(counterOfferRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + OfferCounterOffer co = offerService.createCounterOffer(STUDENT_ID, OFFER_ID, buildCounterOfferReq(), ctx); + assertEquals(OfferCounterOffer.STATUS_PENDING, co.getStatus()); + } + + @Test + void createCounterOffer_otherStudent_shouldFail() { + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("SENT"))); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.createCounterOffer(OTHER_STUDENT_ID, OFFER_ID, buildCounterOfferReq(), ctx)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createCounterOffer_draft_shouldFail() { + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("DRAFT"))); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.createCounterOffer(STUDENT_ID, OFFER_ID, buildCounterOfferReq(), ctx)); + assertEquals(ErrorCode.OFFER_NOT_NEGOTIABLE.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createCounterOffer_duplicatePending_shouldFail() { + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("NEGOTIATING"))); + when(counterOfferRepository.findByOfferIdAndActiveKeyNotNull(OFFER_ID)) + .thenReturn(Optional.of(buildCounterOffer("PENDING"))); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.createCounterOffer(STUDENT_ID, OFFER_ID, buildCounterOfferReq(), ctx)); + assertEquals(ErrorCode.OFFER_COUNTER_OFFER_EXISTS.getCode(), ex.getErrorCodeInt()); + } + + @Test + void createCounterOffer_expiredOffer_shouldFail() { + Offer expired = buildOffer("SENT"); + expired.setExpireAt(LocalDateTime.now().minusHours(1)); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(expired)); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.createCounterOffer(STUDENT_ID, OFFER_ID, buildCounterOfferReq(), ctx)); + assertEquals(ErrorCode.OFFER_EXPIRED.getCode(), ex.getErrorCodeInt()); + } + + @Test + void acceptCounterOffer_shouldSucceed() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + mockCompanyById(COMPANY_ID, COMPANY_USER_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("NEGOTIATING"))); + when(counterOfferRepository.findById(1L)).thenReturn(Optional.of(buildCounterOffer("PENDING"))); + when(offerRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(counterOfferRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + OfferCounterOffer co = offerService.acceptCounterOffer(COMPANY_USER_ID, OFFER_ID, 1L, ctx); + assertEquals(OfferCounterOffer.STATUS_ACCEPTED, co.getStatus()); + assertNull(co.getActiveKey()); + } + + @Test + void acceptCounterOffer_notPending_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("NEGOTIATING"))); + when(counterOfferRepository.findById(1L)).thenReturn(Optional.of(buildCounterOffer("ACCEPTED"))); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.acceptCounterOffer(COMPANY_USER_ID, OFFER_ID, 1L, ctx)); + assertEquals(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING.getCode(), ex.getErrorCodeInt()); + } + + @Test + void rejectCounterOffer_shouldSucceed() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("NEGOTIATING"))); + when(counterOfferRepository.findById(1L)).thenReturn(Optional.of(buildCounterOffer("PENDING"))); + when(counterOfferRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + + OfferCounterOffer co = offerService.rejectCounterOffer(COMPANY_USER_ID, OFFER_ID, 1L, ctx); + assertEquals(OfferCounterOffer.STATUS_REJECTED, co.getStatus()); + assertNull(co.getActiveKey()); + } + + @Test + void rejectCounterOffer_idempotent_shouldReturnExisting() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("NEGOTIATING"))); + when(counterOfferRepository.findById(1L)).thenReturn(Optional.of(buildCounterOffer("REJECTED"))); + + OfferCounterOffer co = offerService.rejectCounterOffer(COMPANY_USER_ID, OFFER_ID, 1L, ctx); + assertEquals(OfferCounterOffer.STATUS_REJECTED, co.getStatus()); + verify(counterOfferRepository, never()).saveAndFlush(any()); + } + + @Test + void acceptCounterOffer_wrongCompany_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, OTHER_COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("NEGOTIATING"))); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.acceptCounterOffer(COMPANY_USER_ID, OFFER_ID, 1L, ctx)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test + void transition_withdrawn_shouldSucceed() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("SENT"))); + stubSaveAndFlush(); + Offer offer = offerService.transitionStatus(COMPANY_USER_ID, OFFER_ID, statusReq("WITHDRAWN"), ctx); + assertEquals("WITHDRAWN", offer.getStatus()); + assertNull(offer.getActiveKey()); + verify(counterOfferRepository).expirePendingByOfferId(OFFER_ID); + } + + @Test + void transition_withdrawn_accepted_shouldFail() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.findById(OFFER_ID)).thenReturn(Optional.of(buildOffer("ACCEPTED"))); + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.transitionStatus(COMPANY_USER_ID, OFFER_ID, statusReq("WITHDRAWN"), ctx)); + assertEquals(ErrorCode.INVALID_OFFER_STATUS_TRANSITION.getCode(), ex.getErrorCodeInt()); + } + + // ── Offer 比较 ──────────────────────────────────────────── + + @Test + void compareOffers_shouldSucceed() { + List ids = List.of(1L, 2L); + when(offerRepository.findByIdInAndCandidateIdAndStatusNotDraftWithCompany( + Set.of(1L, 2L), STUDENT_ID)) + .thenReturn(Arrays.asList( + new Object[]{buildOffer("SENT"), "测试公司"}, + new Object[]{buildOffer("ACCEPTED"), "测试公司"})); + + List> result = offerService.compareOffers(STUDENT_ID, ids); + assertEquals(2, result.size()); + assertEquals("测试公司", result.get(0).get("companyName")); + } + + @Test + void compareOffers_tooFew_shouldFail() { + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.compareOffers(STUDENT_ID, List.of(1L))); + assertEquals(ErrorCode.OFFER_COMPARE_TOO_MANY.getCode(), ex.getErrorCodeInt()); + } + + @Test + void compareOffers_tooMany_shouldFail() { + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.compareOffers(STUDENT_ID, List.of(1L, 2L, 3L, 4L, 5L, 6L))); + assertEquals(ErrorCode.OFFER_COMPARE_TOO_MANY.getCode(), ex.getErrorCodeInt()); + } + + @Test + void compareOffers_duplicateIds_shouldFail() { + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.compareOffers(STUDENT_ID, List.of(1L, 1L))); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test + void compareOffers_notAllOwn_shouldFail() { + when(offerRepository.findByIdInAndCandidateIdAndStatusNotDraftWithCompany( + Set.of(1L, 2L), STUDENT_ID)).thenReturn(Arrays.asList( + new Object[]{buildOffer("SENT"), "测试公司"})); + + BusinessException ex = assertThrows(BusinessException.class, + () -> offerService.compareOffers(STUDENT_ID, List.of(1L, 2L))); + assertEquals(ErrorCode.OFFER_COMPARE_NOT_OWNER.getCode(), ex.getErrorCodeInt()); + } + + // ── Offer 统计 ──────────────────────────────────────────── + + @Test + void statistics_shouldReturnAggregatedData() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.countByCompanyIdAndStatusSince(eq(COMPANY_ID), any())) + .thenReturn(Arrays.asList( + new Object[]{"SENT", 5L}, + new Object[]{"ACCEPTED", 3L}, + new Object[]{"REJECTED", 2L})); + when(historyRepository.findByOfferCompanyAndSince(eq(COMPANY_ID), any())) + .thenReturn(List.of()); + + Map stats = offerService.getStatistics(COMPANY_USER_ID, 6); + assertEquals(10L, stats.get("totalOffers")); + @SuppressWarnings("unchecked") + Map byStatus = (Map) stats.get("byStatus"); + assertEquals(5L, byStatus.get("SENT")); + assertEquals(3L, byStatus.get("ACCEPTED")); + assertEquals(0.6, (Double) stats.get("acceptanceRate"), 0.001); + assertNull(stats.get("avgResponseHours")); + assertEquals(6, stats.get("periodMonths")); + } + + @Test + void statistics_noData_shouldReturnNullRate() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.countByCompanyIdAndStatusSince(eq(COMPANY_ID), any())) + .thenReturn(List.of()); + when(historyRepository.findByOfferCompanyAndSince(eq(COMPANY_ID), any())) + .thenReturn(List.of()); + + Map stats = offerService.getStatistics(COMPANY_USER_ID, 6); + assertEquals(0L, stats.get("totalOffers")); + assertNull(stats.get("acceptanceRate")); + assertNull(stats.get("avgResponseHours")); + } + + @Test + void statistics_invalidMonths_shouldDefaultTo6() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.countByCompanyIdAndStatusSince(eq(COMPANY_ID), any())) + .thenReturn(List.of()); + when(historyRepository.findByOfferCompanyAndSince(eq(COMPANY_ID), any())) + .thenReturn(List.of()); + + Map stats = offerService.getStatistics(COMPANY_USER_ID, 25); + assertEquals(6, stats.get("periodMonths")); + } + + @Test + void statistics_avgResponseHours_shouldBeComputed() { + mockCompanyUser(COMPANY_USER_ID); + mockCompany(COMPANY_USER_ID, COMPANY_ID); + when(offerRepository.countByCompanyIdAndStatusSince(eq(COMPANY_ID), any())) + .thenReturn(Arrays.asList(new Object[]{"ACCEPTED", 1L})); + // SENT at T, ACCEPTED at T+2h + LocalDateTime now = LocalDateTime.now(); + OfferStatusHistory sent = OfferStatusHistory.builder().offerId(1L).toStatus("SENT") + .createdAt(now.minusHours(2)).build(); + OfferStatusHistory accepted = OfferStatusHistory.builder().offerId(1L).toStatus("ACCEPTED") + .createdAt(now).build(); + when(historyRepository.findByOfferCompanyAndSince(eq(COMPANY_ID), any())) + .thenReturn(List.of(sent, accepted)); + + Map stats = offerService.getStatistics(COMPANY_USER_ID, 6); + assertEquals(2.0, (Double) stats.get("avgResponseHours"), 0.1); + } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java new file mode 100644 index 0000000..4cf54f1 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java @@ -0,0 +1,318 @@ +package com.mydreamjob.privacy; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * GET /api/v1/profile/me/privacy 与 PUT /api/v1/profile/me/privacy 契约集成测试。 + * + *

覆盖:STUDENT 成功、GET 无记录返回默认且不写库、角色 403、未登录 401、 + * userId 参数注入无效、非法枚举/缺参 400、version 冲突 409/9201、敏感字段不泄露、OpenAPI 一致性。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class PrivacySettingsControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private StudentPrivacySettingsRepository privacyRepository; + + @MockBean private org.springframework.data.redis.core.RedisTemplate redisTemplate; + + private final ObjectMapper mapper = new ObjectMapper(); + private static final AtomicLong PHONE = new AtomicLong(910_000_000L); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + org.springframework.data.redis.core.ValueOperations ops = + mock(org.springframework.data.redis.core.ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + // 不得 deleteAll;通过自增 phone 保证用户唯一。 + } + + private long createUser(User.Role role) { + User u = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .roles(Set.of(role)) + .role(role) + .active(true) + .build()); + return u.getId(); + } + + private String studentToken(long userId) { + return "Bearer " + jwtTokenProvider.generateAccessToken( + userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT")); + } + + // ── GET:STUDENT 成功 + 无记录默认值 ────────────────────── + + @Test + void getPrivacy_noRecord_returns200Default_andDoesNotWriteDb() throws Exception { + long uid = createUser(User.Role.STUDENT); + long countBefore = privacyRepository.count(); + + String body = mockMvc.perform(get("/profile/me/privacy") + .header("Authorization", studentToken(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.resumeVisibility").value("PRIVATE")) + .andExpect(jsonPath("$.data.contactVisible").value(false)) + .andExpect(jsonPath("$.data.version").value(0)) + .andExpect(jsonPath("$.data.studentUserId").value(uid)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + + JsonNode data = mapper.readTree(body).path("data"); + assertFalse(data.hasNonNull("id"), "无记录时不应返回 id"); + assertFalse(data.hasNonNull("createdAt"), "无记录时不应返回 createdAt"); + assertEquals(privacyRepository.count(), countBefore, "查询不应写入 student_privacy_settings 记录"); + } + + @Test + void getPrivacy_afterPut_returnsStoredValues() throws Exception { + long uid = createUser(User.Role.STUDENT); + + mockMvc.perform(put("/profile/me/privacy") + .header("Authorization", studentToken(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"resumeVisibility\":\"VERIFIED_COMPANIES\",\"contactVisible\":true}")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/profile/me/privacy") + .header("Authorization", studentToken(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.resumeVisibility").value("VERIFIED_COMPANIES")) + .andExpect(jsonPath("$.data.contactVisible").value(true)); + } + + // ── PUT:成功 + upsert 单行 ─────────────────────────────── + + @Test + void putPrivacy_firstUpdate_returns200() throws Exception { + long uid = createUser(User.Role.STUDENT); + + mockMvc.perform(put("/profile/me/privacy") + .header("Authorization", studentToken(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"resumeVisibility\":\"APPLIED_COMPANIES\",\"contactVisible\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.resumeVisibility").value("APPLIED_COMPANIES")) + .andExpect(jsonPath("$.data.contactVisible").value(true)); + } + + @Test + void putPrivacy_repeatedly_keepsSingleRow() throws Exception { + long uid = createUser(User.Role.STUDENT); + + mockMvc.perform(put("/profile/me/privacy") + .header("Authorization", studentToken(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"resumeVisibility\":\"PRIVATE\",\"contactVisible\":false}")) + .andExpect(status().isOk()); + + mockMvc.perform(put("/profile/me/privacy") + .header("Authorization", studentToken(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"resumeVisibility\":\"VERIFIED_COMPANIES\",\"contactVisible\":true}")) + .andExpect(status().isOk()); + + long count = privacyRepository.findByStudentUserId(uid).map(s -> 1L).orElse(0L); + assertEquals(1L, count, "同一学生必须始终只有一行"); + } + + @Test + void putPrivacy_versionConflict_returns409_9201() throws Exception { + long uid = createUser(User.Role.STUDENT); + + // 先创建,当前 version=0 + mockMvc.perform(put("/profile/me/privacy") + .header("Authorization", studentToken(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"resumeVisibility\":\"PRIVATE\",\"contactVisible\":false}")) + .andExpect(status().isOk()); + + // 传不匹配的 version=5 → 409/9201 + mockMvc.perform(put("/profile/me/privacy") + .header("Authorization", studentToken(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"resumeVisibility\":\"VERIFIED_COMPANIES\",\"contactVisible\":true,\"version\":5}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9201)); + } + + // ── 校验失败 ───────────────────────────────────────────── + + @Test + void putPrivacy_invalidEnum_returns400() throws Exception { + long uid = createUser(User.Role.STUDENT); + + mockMvc.perform(put("/profile/me/privacy") + .header("Authorization", studentToken(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"resumeVisibility\":\"INVALID_VALUE\",\"contactVisible\":true}")) + .andExpect(status().isBadRequest()); + } + + @Test + void putPrivacy_missingFields_returns400() throws Exception { + long uid = createUser(User.Role.STUDENT); + + mockMvc.perform(put("/profile/me/privacy") + .header("Authorization", studentToken(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + + // ── 认证 / 角色 ────────────────────────────────────────── + + @Test + void getPrivacy_noToken_returns401() throws Exception { + mockMvc.perform(get("/profile/me/privacy")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void getPrivacy_invalidToken_returns401() throws Exception { + mockMvc.perform(get("/profile/me/privacy") + .header("Authorization", "Bearer not-a-valid-token")) + .andExpect(status().isUnauthorized()); + } + + @Test + void getPrivacy_companyToken_returns403_2002() throws Exception { + long uid = createUser(User.Role.COMPANY); + mockMvc.perform(get("/profile/me/privacy") + .header("Authorization", studentToken(uid))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + @Test + void getPrivacy_adminToken_returns403_2002() throws Exception { + long uid = createUser(User.Role.ADMIN); + mockMvc.perform(get("/profile/me/privacy") + .header("Authorization", studentToken(uid))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + @Test + void putPrivacy_companyToken_returns403_2002() throws Exception { + long uid = createUser(User.Role.COMPANY); + mockMvc.perform(put("/profile/me/privacy") + .header("Authorization", studentToken(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"resumeVisibility\":\"PRIVATE\",\"contactVisible\":false}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + // ── 参数注入防护 ───────────────────────────────────────── + + @Test + void putPrivacy_ignoresUserIdParam_usesJwtIdentity() throws Exception { + long uid = createUser(User.Role.STUDENT); + + mockMvc.perform(put("/profile/me/privacy") + .header("Authorization", studentToken(uid)) + .contentType(MediaType.APPLICATION_JSON) + .param("userId", String.valueOf(uid * 999 + 1)) + .content("{\"resumeVisibility\":\"PRIVATE\",\"contactVisible\":false}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.studentUserId").value(uid)); + } + + // ── 敏感字段不泄露(审计不写敏感值已在 ServiceTest 覆盖) ── + + @Test + void getPrivacy_okResponse_forbidsSensitiveFields() throws Exception { + long uid = createUser(User.Role.STUDENT); + String body = mockMvc.perform(get("/profile/me/privacy") + .header("Authorization", studentToken(uid))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + + String normalized = body.toLowerCase(); + assertFalse(normalized.contains("password")); + assertFalse(normalized.contains("phone")); + assertFalse(normalized.contains("email")); + assertFalse(normalized.contains("jwt")); + assertFalse(normalized.contains("resumeurl")); + } + + // ── OpenAPI 一致性 ─────────────────────────────────────── + + @Test + void openApi_shouldDocumentPrivacyContract() throws Exception { + String json = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode root = mapper.readTree(json); + + JsonNode get = root.path("paths").path("/profile/me/privacy").path("get"); + JsonNode put = root.path("paths").path("/profile/me/privacy").path("put"); + assertThat(get.isMissingNode()).isFalse(); + assertThat(put.isMissingNode()).isFalse(); + + // 引用明确 DTO,而非 Map + assertTrue(get.toString().contains("PrivacySettingsResponse")); + assertTrue(put.toString().contains("PrivacySettingsResponse")); + // 安全要求 + assertTrue(get.path("security").toString().contains("bearerAuth")); + assertTrue(put.path("security").toString().contains("bearerAuth")); + // 无请求参数 + assertThat(put.path("parameters").isMissingNode() + || (put.path("parameters").isArray() && put.path("parameters").isEmpty())).isTrue(); + + // 响应声明 + assertThat(get.path("responses").has("200")).isTrue(); + assertThat(get.path("responses").has("401")).isTrue(); + assertThat(get.path("responses").has("403")).isTrue(); + assertThat(put.path("responses").has("400")).isTrue(); + assertThat(put.path("responses").has("409")).isTrue(); + + // 请求体 schema 引用 DTO + assertThat(put.path("requestBody").toString().contains("PrivacySettingsUpdateRequest")).isTrue(); + + // 无敏感字段 + String schemas = root.path("components").path("schemas").toString().toLowerCase(); + assertFalse(schemas.contains("password")); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsPersistenceIntegrationTest.java new file mode 100644 index 0000000..24d6acb --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsPersistenceIntegrationTest.java @@ -0,0 +1,196 @@ +package com.mydreamjob.privacy; + +import com.mydreamjob.audit.AuditLog; +import com.mydreamjob.audit.AuditLogRepository; +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.privacy.dto.PrivacySettingsResponse; +import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 隐私设置持久化集成测试(H2 MODE=MySQL)。 + * + *

覆盖:GET 无记录不写库、GET 前后行数不变、PUT 首次创建、PUT 重复更新仍一行、 + * version 冲突 409/9201、审计日志存在且无敏感值、枚举各取值的持久化。 + */ +@SpringBootTest +@ActiveProfiles("integration-test") +class PrivacySettingsPersistenceIntegrationTest { + + @Autowired private PrivacySettingsService service; + @Autowired private StudentPrivacySettingsRepository privacyRepository; + @Autowired private UserRepository userRepository; + @Autowired private AuditLogRepository auditLogRepository; + @Autowired private EntityManager entityManager; + + @MockBean private RedisTemplate redisTemplate; + + private static final AtomicLong PHONE = new AtomicLong(920_000_000L); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + org.springframework.data.redis.core.ValueOperations ops = + mock(org.springframework.data.redis.core.ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + } + + private Long createStudent() { + User u = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .nickname("隐私测试学生") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()); + return u.getId(); + } + + private PrivacySettingsUpdateRequest request(ResumeVisibility v, Boolean contact) { + PrivacySettingsUpdateRequest req = new PrivacySettingsUpdateRequest(); + req.setResumeVisibility(v); + req.setContactVisible(contact); + return req; + } + + @Test + void get_noRecord_returnsDefault_andDoesNotWriteRow() { + Long uid = createStudent(); + long countBefore = countRows(); + + PrivacySettingsResponse resp = service.getCurrentUserSettings(uid); + + assertEquals(ResumeVisibility.PRIVATE, resp.getResumeVisibility()); + assertEquals(Boolean.FALSE, resp.getContactVisible()); + assertEquals(0L, resp.getVersion()); + assertEquals(countBefore, countRows(), "GET 前后行数必须不变(禁止自动插入)"); + entityManager.clear(); + assertFalse(privacyRepository.findByStudentUserId(uid).isPresent()); + } + + @Test + void put_firstCreate_insertsOneRow() { + Long uid = createStudent(); + long countBefore = countRows(); + + PrivacySettingsResponse resp = service.updateCurrentUserSettings( + uid, request(ResumeVisibility.APPLIED_COMPANIES, true)); + + assertEquals(ResumeVisibility.APPLIED_COMPANIES, resp.getResumeVisibility()); + assertEquals(Boolean.TRUE, resp.getContactVisible()); + assertEquals(countBefore + 1, countRows(), "首次 PUT 应新增一行"); + entityManager.clear(); + var persisted = privacyRepository.findByStudentUserId(uid).orElseThrow(); + assertEquals(ResumeVisibility.APPLIED_COMPANIES, persisted.getResumeVisibility()); + assertEquals(Boolean.TRUE, persisted.getContactVisible()); + } + + @Test + void put_repeatedUpdate_keepsSingleRow() { + Long uid = createStudent(); + long countBefore = countRows(); + + service.updateCurrentUserSettings(uid, request(ResumeVisibility.PRIVATE, false)); + service.updateCurrentUserSettings(uid, request(ResumeVisibility.APPLIED_COMPANIES, true)); + service.updateCurrentUserSettings(uid, request(ResumeVisibility.VERIFIED_COMPANIES, false)); + + assertEquals(countBefore + 1, countRows(), "重复 upsert 必须始终只有一行"); + entityManager.clear(); + var persisted = privacyRepository.findByStudentUserId(uid).orElseThrow(); + assertEquals(ResumeVisibility.VERIFIED_COMPANIES, persisted.getResumeVisibility()); + assertEquals(Boolean.FALSE, persisted.getContactVisible()); + } + + @Test + void put_versionConflict_throws9201() { + Long uid = createStudent(); + service.updateCurrentUserSettings(uid, request(ResumeVisibility.PRIVATE, false)); + + // 当前 version=0,传 version=3 → 冲突 + PrivacySettingsUpdateRequest req = request(ResumeVisibility.VERIFIED_COMPANIES, true); + req.setVersion(3L); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.updateCurrentUserSettings(uid, req)); + assertEquals(ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE.getCode(), ex.getErrorCode()); + } + + @Test + void put_matchingVersion_succeeds() { + Long uid = createStudent(); + PrivacySettingsResponse first = service.updateCurrentUserSettings( + uid, request(ResumeVisibility.PRIVATE, false)); + + PrivacySettingsUpdateRequest req = request(ResumeVisibility.VERIFIED_COMPANIES, true); + req.setVersion(first.getVersion()); + PrivacySettingsResponse second = service.updateCurrentUserSettings(uid, req); + + assertEquals(ResumeVisibility.VERIFIED_COMPANIES, second.getResumeVisibility()); + } + + @Test + void update_writesAuditLog_withoutSensitiveValues() { + Long uid = createStudent(); + service.updateCurrentUserSettings(uid, request(ResumeVisibility.VERIFIED_COMPANIES, true)); + + List logs = auditLogRepository.findAll().stream() + .filter(l -> "UPDATE_PRIVACY_SETTINGS".equals(l.getAction()) + && uid.equals(l.getUserId())) + .collect(Collectors.toList()); + assertFalse(logs.isEmpty(), "应写入 UPDATE_PRIVACY_SETTINGS 审计日志"); + + for (AuditLog l : logs) { + assertNotNull(l.getAction()); + String details = l.getDetails() == null ? "" : l.getDetails().toLowerCase(); + assertFalse(details.contains("phone"), "审计不得含 phone"); + assertFalse(details.contains("email"), "审计不得含 email"); + assertFalse(details.contains("password"), "审计不得含 password"); + assertFalse(details.contains("jwt"), "审计不得含 jwt"); + assertFalse(details.contains("resumeurl"), "审计不得含 resumeUrl"); + } + } + + @Test + void visibilityEnumValues_persistCorrectly() { + Long uid = createStudent(); + + service.updateCurrentUserSettings(uid, request(ResumeVisibility.PRIVATE, false)); + assertEquals(ResumeVisibility.PRIVATE, loaded(uid).getResumeVisibility()); + + service.updateCurrentUserSettings(uid, request(ResumeVisibility.APPLIED_COMPANIES, true)); + assertEquals(ResumeVisibility.APPLIED_COMPANIES, loaded(uid).getResumeVisibility()); + + service.updateCurrentUserSettings(uid, request(ResumeVisibility.VERIFIED_COMPANIES, false)); + assertEquals(ResumeVisibility.VERIFIED_COMPANIES, loaded(uid).getResumeVisibility()); + } + + private long countRows() { + return privacyRepository.count(); + } + + private StudentPrivacySettings loaded(Long uid) { + entityManager.clear(); + return privacyRepository.findByStudentUserId(uid).orElseThrow(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java new file mode 100644 index 0000000..cab1c3c --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java @@ -0,0 +1,288 @@ +package com.mydreamjob.privacy; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.audit.AuditService; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.privacy.dto.PrivacySettingsResponse; +import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PrivacySettingsServiceTest { + + @Mock private StudentPrivacySettingsRepository privacyRepository; + @Mock private UserRepository userRepository; + @Mock private ApplicationRepository applicationRepository; + @Mock private CompanyRepository companyRepository; + @Mock private AuditService auditService; + + @InjectMocks private PrivacySettingsService service; + + private static final Long STUDENT_ID = 10L; + private static final Long COMPANY_ID = 100L; + private static final Long APP_ID = 200L; + + @BeforeEach + void setUp() { + lenient().doNothing().when(auditService).log(any(), any(), any(), any(), any(), any()); + } + + private void mockStudent() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(studentUser())); + } + + private User studentUser() { + User u = User.builder().id(STUDENT_ID).phone("13800000001") + .role(User.Role.STUDENT).active(true).verified(true).build(); + u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + return u; + } + + private User companyUser() { + User u = User.builder().id(99L).phone("13800000002") + .role(User.Role.COMPANY).active(true).build(); + u.setRoles(new HashSet<>(Set.of(User.Role.COMPANY))); + return u; + } + + private StudentPrivacySettings settings(ResumeVisibility v, Boolean contact) { + return StudentPrivacySettings.builder() + .id(1L).studentUserId(STUDENT_ID) + .resumeVisibility(v).contactVisible(contact).version(0L) + .build(); + } + + private PrivacySettingsUpdateRequest request(ResumeVisibility v, Boolean contact, Long version) { + PrivacySettingsUpdateRequest req = new PrivacySettingsUpdateRequest(); + req.setResumeVisibility(v); + req.setContactVisible(contact); + req.setVersion(version); + return req; + } + + // ── GET ────────────────────────────────────────────────── + + @Test + void get_noRecord_returnsDefault_withoutWritingDb() { + mockStudent(); + when(privacyRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); + + PrivacySettingsResponse resp = service.getCurrentUserSettings(STUDENT_ID); + + assertEquals(ResumeVisibility.PRIVATE, resp.getResumeVisibility()); + assertEquals(Boolean.FALSE, resp.getContactVisible()); + assertEquals(0L, resp.getVersion()); + assertEquals(STUDENT_ID, resp.getStudentUserId()); + assertNull(resp.getId()); + verify(privacyRepository, never()).save(any()); + } + + @Test + void get_existing_returnsPersistedValues() { + mockStudent(); + when(privacyRepository.findByStudentUserId(STUDENT_ID)) + .thenReturn(Optional.of(settings(ResumeVisibility.VERIFIED_COMPANIES, true))); + + PrivacySettingsResponse resp = service.getCurrentUserSettings(STUDENT_ID); + + assertEquals(ResumeVisibility.VERIFIED_COMPANIES, resp.getResumeVisibility()); + assertEquals(Boolean.TRUE, resp.getContactVisible()); + } + + @Test + void get_companyRole_throws403() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(companyUser())); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.getCurrentUserSettings(STUDENT_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); + } + + @Test + void get_userNotExist_throwsUserNotFound() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.empty()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> service.getCurrentUserSettings(STUDENT_ID)); + assertEquals(ErrorCode.USER_NOT_FOUND.getCode(), ex.getErrorCode()); + } + + // ── PUT ────────────────────────────────────────────────── + + @Test + void put_firstCreate_insertsSingleRowAndAudits() { + mockStudent(); + when(privacyRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); + when(privacyRepository.saveAndFlush(any())).thenAnswer(inv -> + ((StudentPrivacySettings) inv.getArgument(0)).getId() == null + ? settings(ResumeVisibility.APPLIED_COMPANIES, true) : inv.getArgument(0)); + + PrivacySettingsResponse resp = service.updateCurrentUserSettings( + STUDENT_ID, request(ResumeVisibility.APPLIED_COMPANIES, true, null)); + + assertEquals(ResumeVisibility.APPLIED_COMPANIES, resp.getResumeVisibility()); + assertEquals(Boolean.TRUE, resp.getContactVisible()); + verify(privacyRepository, times(1)).saveAndFlush(any()); + verify(auditService, times(1)).log(eq(STUDENT_ID), eq("UPDATE_PRIVACY_SETTINGS"), + eq("STUDENT_PRIVACY_SETTINGS"), any(), any(), any()); + } + + @Test + void put_update_keepsSingleRow() { + mockStudent(); + when(privacyRepository.findByStudentUserId(STUDENT_ID)) + .thenReturn(Optional.of(settings(ResumeVisibility.PRIVATE, false))); + when(privacyRepository.saveAndFlush(any())).thenAnswer(inv -> + settings(ResumeVisibility.VERIFIED_COMPANIES, true)); + + PrivacySettingsResponse resp = service.updateCurrentUserSettings( + STUDENT_ID, request(ResumeVisibility.VERIFIED_COMPANIES, true, 0L)); + + assertEquals(ResumeVisibility.VERIFIED_COMPANIES, resp.getResumeVisibility()); + assertEquals(Boolean.TRUE, resp.getContactVisible()); + verify(privacyRepository, times(1)).saveAndFlush(any()); + } + + @Test + void put_versionConflict_throws9201() { + mockStudent(); + when(privacyRepository.findByStudentUserId(STUDENT_ID)) + .thenReturn(Optional.of(settings(ResumeVisibility.PRIVATE, false))); + + // 当前版本 0,客户端传 5 → 冲突 + BusinessException ex = assertThrows(BusinessException.class, + () -> service.updateCurrentUserSettings( + STUDENT_ID, request(ResumeVisibility.VERIFIED_COMPANIES, true, 5L))); + assertEquals(ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE.getCode(), ex.getErrorCode()); + verify(privacyRepository, never()).saveAndFlush(any()); + } + + @Test + void put_nullEnum_throwsBadRequest() { + mockStudent(); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.updateCurrentUserSettings(STUDENT_ID, request(null, true, null))); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); + } + + @Test + void put_nullContactVisible_throwsBadRequest() { + mockStudent(); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.updateCurrentUserSettings(STUDENT_ID, request(ResumeVisibility.PRIVATE, null, null))); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); + } + + @Test + void put_companyRole_throws403() { + when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(companyUser())); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.updateCurrentUserSettings( + STUDENT_ID, request(ResumeVisibility.PRIVATE, true, null))); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); + } + + // ── 隐私策略 ───────────────────────────────────────────── + + @Test + void canCompanyViewResume_private_alwaysFalseEvenWithVerifiedCompany() { + when(privacyRepository.findByStudentUserId(STUDENT_ID)) + .thenReturn(Optional.of(settings(ResumeVisibility.PRIVATE, false))); + assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); + } + + @Test + void canCompanyViewResume_defaultPrivate_whenNoRecord() { + when(privacyRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); + assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); + } + + @Test + void canCompanyViewResume_appliedCompanies_validApplication_visible() { + when(privacyRepository.findByStudentUserId(STUDENT_ID)) + .thenReturn(Optional.of(settings(ResumeVisibility.APPLIED_COMPANIES, false))); + Application app = Application.builder().id(APP_ID).studentUserId(STUDENT_ID).status("SUBMITTED").build(); + when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(app)); + + assertTrue(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); + } + + @Test + void canCompanyViewResume_appliedCompanies_withdrawn_notVisible() { + when(privacyRepository.findByStudentUserId(STUDENT_ID)) + .thenReturn(Optional.of(settings(ResumeVisibility.APPLIED_COMPANIES, false))); + Application app = Application.builder().id(APP_ID).studentUserId(STUDENT_ID).status("WITHDRAWN").build(); + when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(app)); + + assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); + } + + @Test + void canCompanyViewResume_appliedCompanies_rejected_notVisible() { + when(privacyRepository.findByStudentUserId(STUDENT_ID)) + .thenReturn(Optional.of(settings(ResumeVisibility.APPLIED_COMPANIES, false))); + Application app = Application.builder().id(APP_ID).studentUserId(STUDENT_ID).status("REJECTED").build(); + when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(app)); + + assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); + } + + @Test + void canCompanyViewResume_appliedCompanies_wrongStudent_notVisible() { + when(privacyRepository.findByStudentUserId(STUDENT_ID)) + .thenReturn(Optional.of(settings(ResumeVisibility.APPLIED_COMPANIES, false))); + Application app = Application.builder().id(APP_ID).studentUserId(999L).status("SUBMITTED").build(); + when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(app)); + + assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); + } + + @Test + void canCompanyViewResume_verifiedCompanies_verifiedCompany_visible() { + when(privacyRepository.findByStudentUserId(STUDENT_ID)) + .thenReturn(Optional.of(settings(ResumeVisibility.VERIFIED_COMPANIES, false))); + when(companyRepository.findById(COMPANY_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).verified(true).build())); + + assertTrue(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); + } + + @Test + void canCompanyViewResume_verifiedCompanies_unverifiedCompany_notVisible() { + when(privacyRepository.findByStudentUserId(STUDENT_ID)) + .thenReturn(Optional.of(settings(ResumeVisibility.VERIFIED_COMPANIES, false))); + when(companyRepository.findById(COMPANY_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).verified(false).build())); + + assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); + } + + @Test + void canCompanyViewResume_verifiedCompanies_unknownCompany_notVisible() { + when(privacyRepository.findByStudentUserId(STUDENT_ID)) + .thenReturn(Optional.of(settings(ResumeVisibility.VERIFIED_COMPANIES, false))); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.empty()); + + assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java new file mode 100644 index 0000000..6bdd31d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java @@ -0,0 +1,265 @@ +package com.mydreamjob.profile; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * GET /api/v1/profile/me 接口契约集成测试。 + * + *

覆盖:成功返回、空资料返回空对象且不写库、未登录 401、非法 token 401、 + * COMPANY/ADMIN 403、拒绝 userId 参数注入、不泄露敏感字段、PUT 回归不受影响、 + * OpenAPI 文档一致性。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class ProfileControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private StudentProfileRepository profileRepository; + + @MockBean private RedisTemplate redisTemplate; + + private final ObjectMapper mapper = new ObjectMapper(); + // JUnit 默认按方法新建实例,必须用静态原子计数保证跨实例唯一手机号 + private static final java.util.concurrent.atomic.AtomicLong PHONE = + new java.util.concurrent.atomic.AtomicLong(900_000_000L); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + org.springframework.data.redis.core.ValueOperations ops = + mock(org.springframework.data.redis.core.ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + // 注意:不得 deleteAll(userRepository/profileRepository)。 + // H2 为整套测试共享,其他集成测试可能残留引用本表的行,deleteAll 会触发外键冲突。 + // 通过每次自增的 phone 保证用户唯一。 + } + + // ── 身份构造(使用数据库自增 id,避免手工 id 触发合并级乐观锁) ── + + /** 创建一个指定角色的用户,返回数据库生成的自增 id。 */ + private long createUser(User.Role role) { + User u = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .roles(Set.of(role)) + .role(role) + .active(true) + .build()); + return u.getId(); + } + + private String bearerToken(long userId, String phone) { + return "Bearer " + jwtTokenProvider.generateAccessToken( + userId, phone, Map.of("roles", "STUDENT")); + } + + private String studentToken(long userId) { + return bearerToken(userId, String.valueOf(PHONE.getAndIncrement())); + } + + /** 创建 STUDENT 用户 + 个人资料,返回 {userId, token}。 */ + private long createProfileForStudent(long userId, String realName) { + profileRepository.save(StudentProfile.builder() + .userId(userId) + .realName(realName) + .school("示例大学") + .skillsJson("[Java, Spring Boot]") + .build()); + return userId; + } + + // ── 成功场景 ────────────────────────────────────────────── + + @Test + void getProfile_withExistingProfile_returns200WithFullFields() throws Exception { + long uid = createUser(User.Role.STUDENT); + createProfileForStudent(uid, "张三"); + + String body = mockMvc.perform(get("/profile/me") + .header("Authorization", studentToken(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.userId").value(uid)) + .andExpect(jsonPath("$.data.realName").value("张三")) + .andExpect(jsonPath("$.data.school").value("示例大学")) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + + JsonNode data = mapper.readTree(body).path("data"); + assertTrue(data.hasNonNull("id"), "有资料时应返回资料 id"); + assertTrue(data.hasNonNull("createdAt"), "有资料时应返回创建时间"); + // skills 当前以字符串返回(保持兼容),而非数组 + assertTrue(data.path("skills").isTextual(), "skills 应为字符串"); + assertTrue(data.path("skills").asText().contains("Java"), "skills 应含技能内容"); + } + + // ── 空资料场景 ──────────────────────────────────────────── + + @Test + void getProfile_noProfile_returns200EmptyObject_andDoesNotWriteDb() throws Exception { + long uid = createUser(User.Role.STUDENT); + long countBefore = profileRepository.count(); + assertFalse(profileRepository.existsByUserId(uid)); + + String body = mockMvc.perform(get("/profile/me") + .header("Authorization", studentToken(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + + JsonNode data = mapper.readTree(body).path("data"); + assertTrue(data.isObject(), "data 必须是 JSON 对象"); + assertEquals(0, data.size(), "空资料时 data 应为空对象 {}"); + + assertEquals(countBefore, profileRepository.count(), "查询不应写入 student_profiles 记录"); + assertFalse(profileRepository.existsByUserId(uid)); + } + + // ── 认证失败场景 ────────────────────────────────────────── + + @Test + void getProfile_noToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/profile/me")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void getProfile_invalidToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/profile/me") + .header("Authorization", "Bearer not-a-valid-token")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + // ── 角色校验场景(服务层,需 DB 中存在对应角色用户) ────── + + @Test + void getProfile_companyToken_shouldReturn403() throws Exception { + long uid = createUser(User.Role.COMPANY); + mockMvc.perform(get("/profile/me") + .header("Authorization", studentToken(uid))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + @Test + void getProfile_adminToken_shouldReturn403() throws Exception { + long uid = createUser(User.Role.ADMIN); + mockMvc.perform(get("/profile/me") + .header("Authorization", studentToken(uid))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + // ── 参数注入防护 ────────────────────────────────────────── + + @Test + void getProfile_ignoresUserIdQueryParam_usesJwtIdentity() throws Exception { + long uid = createUser(User.Role.STUDENT); + createProfileForStudent(uid, "张三"); + + // 传入别的 userId,必须仍返回 JWT 身份对应资料 + String body = mockMvc.perform(get("/profile/me") + .header("Authorization", studentToken(uid)) + .param("userId", String.valueOf(uid * 999 + 1))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + + JsonNode data = mapper.readTree(body).path("data"); + assertEquals(uid, data.path("userId").asLong()); + } + + // ── 敏感字段不泄露 ──────────────────────────────────────── + + @Test + void getProfile_responseForbidsSensitiveFields() throws Exception { + long uid = createUser(User.Role.STUDENT); + createProfileForStudent(uid, "张三"); + + String body = mockMvc.perform(get("/profile/me") + .header("Authorization", studentToken(uid))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + + String normalized = body.toLowerCase(); + assertFalse(normalized.contains("password")); + assertFalse(normalized.contains("token")); + assertFalse(normalized.contains("jwt")); + assertFalse(normalized.contains("verification")); + assertFalse(normalized.contains("smscode")); + } + + // ── 回归:PUT /profile/me 不受影响 ──────────────────────── + + @Test + void putProfileMe_studentToken_shouldStillReturn200() throws Exception { + long uid = createUser(User.Role.STUDENT); + mockMvc.perform(put("/profile/me") + .header("Authorization", studentToken(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"realName\":\"李四\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + } + + // ── OpenAPI 一致性 ──────────────────────────────────────── + + @Test + void openApi_shouldDocumentProfileMeContract() throws Exception { + String json = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode root = mapper.readTree(json); + + JsonNode get = root.path("paths").path("/profile/me").path("get"); + assertThat(get.isMissingNode()).isFalse(); + // 引用明确 DTO,而非 Map + assertTrue(get.path("responses").path("200").toString().contains("StudentProfileResponse")); + // 安全要求 + assertTrue(get.path("security").toString().contains("bearerAuth")); + // 无请求参数(SpringDoc 缺省时可能省略 parameters 字段) + JsonNode params = get.path("parameters"); + assertTrue(params.isMissingNode() || (params.isArray() && params.isEmpty())); + // 401/403 响应声明 + assertThat(get.path("responses").has("401")).isTrue(); + assertThat(get.path("responses").has("403")).isTrue(); + + // 无敏感字段(DTO schema 不含 password 等) + String schemas = root.path("components").path("schemas").toString().toLowerCase(); + assertFalse(schemas.contains("password")); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java index e9bca1c..8e7a9a4 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java @@ -74,10 +74,27 @@ class ProfileServiceTest { assertEquals(20L, saved.getUserId()); assertEquals("李四", saved.getRealName()); assertEquals("北京大学", saved.getSchool()); - assertEquals("[Java, Spring Boot]", saved.getSkillsJson()); + // skills 必须序列化为合法 JSON 数组,可安全写入 MySQL skills_json JSON 列 + assertEquals("[\"Java\",\"Spring Boot\"]", saved.getSkillsJson()); verify(profileRepository, times(1)).save(any()); } + @Test + void updateProfile_skillsNull_preservesExistingSkillsJson() { + profile.setSkillsJson("[\"Java\",\"MySQL\"]"); + StudentProfileUpdateRequest req = new StudentProfileUpdateRequest(); + req.setRealName("李四"); + // skills 不传(null),应保留原有 skills_json,不清空、不覆盖 + + when(profileRepository.findByUserId(10L)).thenReturn(Optional.of(profile)); + when(profileRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + StudentProfile updated = profileService.updateProfile(10L, req); + + assertEquals("[\"Java\",\"MySQL\"]", updated.getSkillsJson()); + verify(profileRepository, times(1)).save(profile); + } + @Test void updateProfile_partialUpdate_preservesUnchangedFields() { StudentProfileUpdateRequest req = new StudentProfileUpdateRequest(); @@ -112,4 +129,32 @@ class ProfileServiceTest { assertEquals("示例大学", view.get("school")); assertEquals(10L, view.get("userId")); } + + @Test + void toResponse_profileNull_returnsEmptyDtoObject() { + var resp = profileService.toResponse(null); + assertNotNull(resp); + assertNull(resp.getId()); + assertNull(resp.getUserId()); + } + + @Test + void toResponse_profileNotNull_returnsDtoFields() { + StudentProfile p = StudentProfile.builder() + .id(7L) + .userId(10L) + .realName("张三") + .school("示例大学") + .graduationYear((short) 2026) + .skillsJson("[Java, Spring Boot]") + .build(); + var resp = profileService.toResponse(p); + assertNotNull(resp); + assertEquals(7L, resp.getId()); + assertEquals(10L, resp.getUserId()); + assertEquals("张三", resp.getRealName()); + assertEquals("示例大学", resp.getSchool()); + assertEquals((short) 2026, resp.getGraduationYear()); + assertEquals("[Java, Spring Boot]", resp.getSkills()); + } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/safety/AdminReportControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/safety/AdminReportControllerTest.java new file mode 100644 index 0000000..4e8aa9a --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/safety/AdminReportControllerTest.java @@ -0,0 +1,200 @@ +package com.mydreamjob.safety; + +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.audit.AuditLogRepository; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.notification.NotificationRepository; +import com.mydreamjob.safety.dto.CreateReportRequest; +import com.mydreamjob.safety.dto.ReportResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * ADMIN 举报管理集成测试(V33)。覆盖列表/详情/处理、权限矩阵、状态机、处理后通知幂等。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class AdminReportControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private ReportService reportService; + @Autowired private ReportRepository reportRepository; + @Autowired private ReportDecisionHistoryRepository historyRepository; + @Autowired private NotificationRepository notificationRepository; + @Autowired private AuditLogRepository auditLogRepository; + + @MockBean private RedisTemplate redisTemplate; + + private static final AtomicLong PHONE = new AtomicLong(1900); + + /** 本类本次运行创建的 userId,tearDown 仅删除这些 user,避免触发共享 H2 中跨类 applications FK 违约。 */ + private final List createdUserIds = new ArrayList<>(); + + private Long reporter; + private Long target; + private Long admin; + private Long company; + private Long reportId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + reporter = createUser("Reporter", User.Role.STUDENT); + target = createUser("Target", User.Role.STUDENT); + admin = createUser("Admin", User.Role.ADMIN); + company = createUser("Company", User.Role.COMPANY); + + ReportResponse created = reportService.create(reporter, CreateReportRequest.builder() + .targetType("USER").targetUserId(target).category("HARASSMENT").reason("骚扰").build(), + AuditContext.builder().build()); + reportId = created.getId(); + } + + @AfterEach + void tearDown() { + reportRepository.deleteAll(); + historyRepository.deleteAll(); + notificationRepository.deleteAll(); + auditLogRepository.deleteAll(); + createdUserIds.forEach(userRepository::deleteById); + createdUserIds.clear(); + } + + private Long createUser(String tag, User.Role role) { + Long id = userRepository.save(User.builder() + .phone("137" + String.format("%08d", PHONE.incrementAndGet())) + .nickname(tag).role(role).roles(Set.of(role)).active(true).verified(true).build()).getId(); + createdUserIds.add(id); + return id; + } + + private String token(Long userId) { + return "Bearer " + jwtTokenProvider.generateAccessToken(userId, + String.valueOf(userId), Map.of("roles", "STUDENT")); + } + + private String tokenWithRole(Long userId, User.Role role) { + return "Bearer " + jwtTokenProvider.generateAccessToken(userId, + String.valueOf(userId), Map.of("roles", role.name())); + } + + @Test + void adminList_returns200() throws Exception { + mockMvc.perform(get("/admin/reports").header("Authorization", tokenWithRole(admin, User.Role.ADMIN))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(1)); + } + + @Test + void company_returns403_2002() throws Exception { + mockMvc.perform(get("/admin/reports").header("Authorization", tokenWithRole(company, User.Role.COMPANY))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + @Test + void student_returns403_2002() throws Exception { + mockMvc.perform(get("/admin/reports").header("Authorization", token(reporter))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + @Test + void unauthorized_returns401() throws Exception { + mockMvc.perform(get("/admin/reports")).andExpect(status().isUnauthorized()); + mockMvc.perform(post("/admin/reports/{id}/decision", reportId) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"decision\":\"RESOLVED\",\"note\":\"ok\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void adminDetail_returns200_hasHistoryAfterDecide() throws Exception { + decide(reportId, "RESOLVED", "ok"); + mockMvc.perform(get("/admin/reports/{id}", reportId) + .header("Authorization", tokenWithRole(admin, User.Role.ADMIN))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("RESOLVED")) + .andExpect(jsonPath("$.data.history.length()").value(1)) + .andExpect(jsonPath("$.data.history[0].fromStatus").value("PENDING")) + .andExpect(jsonPath("$.data.history[0].toStatus").value("RESOLVED")) + .andExpect(jsonPath("$.data.decision").value("RESOLVED")); + } + + @Test + void decide_resolved_sendsNotification() throws Exception { + decide(reportId, "RESOLVED", "ok"); + org.junit.jupiter.api.Assertions.assertTrue( + notificationRepository.existsByIdempotencyKey("report-decision-" + reportId)); + } + + @Test + void decide_repeat_returns409_9604() throws Exception { + decide(reportId, "RESOLVED", "ok"); + mockMvc.perform(post("/admin/reports/{id}/decision", reportId) + .header("Authorization", tokenWithRole(admin, User.Role.ADMIN)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"decision\":\"REJECTED\",\"note\":\"again\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9604)); + } + + @Test + void decide_rejected_noNote_returns400() throws Exception { + mockMvc.perform(post("/admin/reports/{id}/decision", reportId) + .header("Authorization", tokenWithRole(admin, User.Role.ADMIN)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"decision\":\"REJECTED\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void decide_student_returns403_2002() throws Exception { + mockMvc.perform(post("/admin/reports/{id}/decision", reportId) + .header("Authorization", token(reporter)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"decision\":\"RESOLVED\",\"note\":\"ok\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + private void decide(Long id, String decision, String note) throws Exception { + mockMvc.perform(post("/admin/reports/{id}/decision", id) + .header("Authorization", tokenWithRole(admin, User.Role.ADMIN)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"decision\":\"" + decision + "\",\"note\":\"" + note + "\"}")) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/safety/BlockControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/safety/BlockControllerTest.java new file mode 100644 index 0000000..8c46a23 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/safety/BlockControllerTest.java @@ -0,0 +1,170 @@ +package com.mydreamjob.safety; + +import com.mydreamjob.audit.AuditLogRepository; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.notification.NotificationRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 屏蔽控制器集成测试(V33)。覆盖屏蔽/解除/列表、幂等、自屏蔽、目标不存在、脱敏与权限。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class BlockControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private BlockRelationRepository blockRepository; + @Autowired private NotificationRepository notificationRepository; + @Autowired private AuditLogRepository auditLogRepository; + + @MockBean private RedisTemplate redisTemplate; + + private static final AtomicLong PHONE = new AtomicLong(1700); + + /** 本类本次运行创建的 userId,tearDown 仅删除这些 user,避免触发共享 H2 中跨类 applications FK 违约。 */ + private final List createdUserIds = new ArrayList<>(); + + private Long me; + private Long target; + private Long other; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + me = createUser("Me"); + target = createUser("Target"); + other = createUser("Other"); + } + + @AfterEach + void tearDown() { + blockRepository.deleteAll(); + notificationRepository.deleteAll(); + auditLogRepository.deleteAll(); + createdUserIds.forEach(userRepository::deleteById); + createdUserIds.clear(); + } + + private Long createUser(String tag) { + Long id = userRepository.save(User.builder() + .phone("136" + String.format("%08d", PHONE.incrementAndGet())) + .nickname(tag).role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)) + .active(true).verified(true).build()).getId(); + createdUserIds.add(id); + return id; + } + + private String token(Long userId) { + return "Bearer " + jwtTokenProvider.generateAccessToken(userId, + String.valueOf(userId), Map.of("roles", "STUDENT")); + } + + @Test + void block_returns200() throws Exception { + mockMvc.perform(put("/blocks/{blocked}", target).header("Authorization", token(me))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.blockedUserId").value(target)); + } + + @Test + void blockSelf_returns400_9608() throws Exception { + mockMvc.perform(put("/blocks/{blocked}", me).header("Authorization", token(me))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(9608)); + } + + @Test + void blockTargetNotExist_returns404_4001() throws Exception { + mockMvc.perform(put("/blocks/{blocked}", 999999L).header("Authorization", token(me))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(4001)); + } + + @Test + void duplicateBlock_isIdempotent_noNewRow() throws Exception { + mockMvc.perform(put("/blocks/{blocked}", target).header("Authorization", token(me))) + .andExpect(status().isOk()); + mockMvc.perform(put("/blocks/{blocked}", target).header("Authorization", token(me))) + .andExpect(status().isOk()); + org.junit.jupiter.api.Assertions.assertEquals(1, + blockRepository.findByBlockerUserId(me).size()); + } + + @Test + void unblock_returns200_idempotent() throws Exception { + mockMvc.perform(put("/blocks/{blocked}", target).header("Authorization", token(me))) + .andExpect(status().isOk()); + mockMvc.perform(delete("/blocks/{blocked}", target).header("Authorization", token(me))) + .andExpect(status().isOk()); + // 再解除一次(不存在)→ 仍 200,不报错 + mockMvc.perform(delete("/blocks/{blocked}", target).header("Authorization", token(me))) + .andExpect(status().isOk()); + org.junit.jupiter.api.Assertions.assertEquals(0, + blockRepository.findByBlockerUserId(me).size()); + } + + @Test + void unblockSelf_returns400_9608() throws Exception { + mockMvc.perform(delete("/blocks/{blocked}", me).header("Authorization", token(me))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(9608)); + } + + @Test + void listMine_returnsOnlyMyBlocks_desensitized() throws Exception { + mockMvc.perform(put("/blocks/{blocked}", target).header("Authorization", token(me))) + .andExpect(status().isOk()); + mockMvc.perform(get("/blocks/me").header("Authorization", token(me))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].blockedUserId").value(target)) + // 脱敏:不含对方联系方式 + .andExpect(jsonPath("$.data.items[0].phone").doesNotExist()) + .andExpect(jsonPath("$.data.items[0].email").doesNotExist()) + .andExpect(jsonPath("$.data.items[0].resumeUrl").doesNotExist()); + // 其他人看不到 me 的屏蔽 + mockMvc.perform(get("/blocks/me").header("Authorization", token(other))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(0)); + } + + @Test + void unauthorized_returns401() throws Exception { + mockMvc.perform(put("/blocks/{blocked}", target)).andExpect(status().isUnauthorized()); + mockMvc.perform(get("/blocks/me")).andExpect(status().isUnauthorized()); + mockMvc.perform(delete("/blocks/{blocked}", target)).andExpect(status().isUnauthorized()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/safety/ReportControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/safety/ReportControllerTest.java new file mode 100644 index 0000000..46df0df --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/safety/ReportControllerTest.java @@ -0,0 +1,322 @@ +package com.mydreamjob.safety; + +import com.mydreamjob.audit.AuditLogRepository; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +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.NotificationRepository; +import com.mydreamjob.safety.dto.CreateReportRequest; +import com.mydreamjob.safety.dto.ReportResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 举报控制器集成测试(V33,真实 Service + H2)。 + * + *

覆盖举报创建、MESSAGE 发送者溯源、参与校验、自举报、幂等、重复、脱敏、限流、路由顺序。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class ReportControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private ConversationRepository conversationRepository; + @Autowired private ConversationMemberRepository memberRepository; + @Autowired private MessageRepository messageRepository; + @Autowired private ReportRepository reportRepository; + @Autowired private NotificationRepository notificationRepository; + @Autowired private AuditLogRepository auditLogRepository; + + @MockBean private RedisTemplate redisTemplate; + + private static final AtomicLong PHONE = new AtomicLong(1300); + + /** + * 本类本次运行创建的 userId。tearDown 仅删除这些 user, + * 避免对共享 H2 'integrationdb' 中其他测试类残留的 applications 行触发候选 FK 违约。 + */ + private final List createdUserIds = new ArrayList<>(); + + private Long me; + private Long target; + private Long other; + private Long admin; + private Long messageId; + private Long conversationId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + me = createUser("Me", User.Role.STUDENT); + target = createUser("Target", User.Role.STUDENT); + other = createUser("Other", User.Role.STUDENT); + admin = createUser("Admin", User.Role.ADMIN); + + // 直接会话 me<->target,并有一条来自 target 的消息 + conversationId = conversationRepository.save( + Conversation.builder().createdBy(me).type("DIRECT").build()).getId(); + memberRepository.save(ConversationMember.builder().conversationId(conversationId).userId(me).build()); + memberRepository.save(ConversationMember.builder().conversationId(conversationId).userId(target).build()); + messageId = messageRepository.save(Message.builder() + .conversationId(conversationId).senderUserId(target).content("恶意骚扰内容").build()).getId(); + } + + @AfterEach + void tearDown() { + reportRepository.deleteAll(); + messageRepository.deleteAll(); + memberRepository.deleteAll(); + conversationRepository.deleteAll(); + notificationRepository.deleteAll(); + auditLogRepository.deleteAll(); + deleteOwnUsers(); + } + + private void deleteOwnUsers() { + createdUserIds.forEach(userRepository::deleteById); + createdUserIds.clear(); + } + + private Long createUser(String tag, User.Role role) { + Long id = userRepository.save(User.builder() + .phone("135" + String.format("%08d", PHONE.incrementAndGet())) + .nickname(tag).role(role).roles(Set.of(role)).active(true).verified(true).build()).getId(); + createdUserIds.add(id); + return id; + } + + private String token(Long userId) { + return "Bearer " + jwtTokenProvider.generateAccessToken(userId, + String.valueOf(userId), Map.of("roles", "STUDENT")); + } + + private String tokenWithRole(Long userId, User.Role role) { + return "Bearer " + jwtTokenProvider.generateAccessToken(userId, + String.valueOf(userId), Map.of("roles", role.name())); + } + + // ── 用户举报 ──────────────────────────────────────────────── + + @Test + void userReport_returns200_pending_desensitized() throws Exception { + String body = body(CreateReportRequest.builder() + .targetType("USER").targetUserId(target).category("HARASSMENT") + .reason("发送骚扰消息").evidenceUrls(List.of("https://oss.example.com/x.png")).build()); + mockMvc.perform(post("/reports").contentType(MediaType.APPLICATION_JSON) + .content(body).header("Authorization", token(me))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.targetType").value("USER")) + .andExpect(jsonPath("$.data.reportedUserId").value(target)) + .andExpect(jsonPath("$.data.reportedUser.userId").value(target)) + // 脱敏:不返回手机号/邮箱 + .andExpect(jsonPath("$.data.reportedUser.phone").doesNotExist()) + .andExpect(jsonPath("$.data.reportedUser.email").doesNotExist()) + .andExpect(jsonPath("$.data.reportedUser.resumeUrl").doesNotExist()); + } + + @Test + void reportSelf_returns400_9602() throws Exception { + String body = body(CreateReportRequest.builder() + .targetType("USER").targetUserId(me).category("HARASSMENT").reason("x").build()); + mockMvc.perform(post("/reports").contentType(MediaType.APPLICATION_JSON) + .content(body).header("Authorization", token(me))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(9602)); + } + + @Test + void reportTargetNotExist_returns404_4001() throws Exception { + String body = body(CreateReportRequest.builder() + .targetType("USER").targetUserId(999999L).category("HARASSMENT").reason("x").build()); + mockMvc.perform(post("/reports").contentType(MediaType.APPLICATION_JSON) + .content(body).header("Authorization", token(me))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(4001)); + } + + // ── 消息举报(服务端溯源) ────────────────────────────────── + + @Test + void messageReport_derivesSender_returns200() throws Exception { + String body = body(CreateReportRequest.builder() + .targetType("MESSAGE").messageId(messageId).category("INAPPROPRIATE_CONTENT") + .reason("消息内容违规").build()); + mockMvc.perform(post("/reports").contentType(MediaType.APPLICATION_JSON) + .content(body).header("Authorization", token(me))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.targetType").value("MESSAGE")) + .andExpect(jsonPath("$.data.messageId").value(messageId)) + .andExpect(jsonPath("$.data.reportedUserId").value(target)) + .andExpect(jsonPath("$.data.messagePreview").exists()); + } + + @Test + void messageReport_notParticipant_returns403_2002() throws Exception { + String body = body(CreateReportRequest.builder() + .targetType("MESSAGE").messageId(messageId).category("SPAM").reason("x").build()); + mockMvc.perform(post("/reports").contentType(MediaType.APPLICATION_JSON) + .content(body).header("Authorization", token(other))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + // ── 幂等 / 重复 ──────────────────────────────────────── + + @Test + void pendingDuplicate_returns409_9603() throws Exception { + String body = body(CreateReportRequest.builder() + .targetType("USER").targetUserId(target).category("HARASSMENT") + .reason("重复").build()); + mockMvc.perform(post("/reports").contentType(MediaType.APPLICATION_JSON) + .content(body).header("Authorization", token(me))).andExpect(status().isOk()); + mockMvc.perform(post("/reports").contentType(MediaType.APPLICATION_JSON) + .content(body).header("Authorization", token(me))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9603)); + } + + @Test + void idempotency_sameBody_returns200_original() throws Exception { + String body = body(CreateReportRequest.builder() + .targetType("USER").targetUserId(target).category("FRAUD").reason("same") + .idempotencyKey("k-1").build()); + mockMvc.perform(post("/reports").contentType(MediaType.APPLICATION_JSON) + .content(body).header("Authorization", token(me))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PENDING")); + mockMvc.perform(post("/reports").contentType(MediaType.APPLICATION_JSON) + .content(body).header("Authorization", token(me))) + .andExpect(status().isOk()); + } + + @Test + void idempotency_diffBody_returns409_9606() throws Exception { + String base = body(CreateReportRequest.builder() + .targetType("USER").targetUserId(target).category("FRAUD").reason("aaa") + .idempotencyKey("k-2").build()); + String diff = body(CreateReportRequest.builder() + .targetType("USER").targetUserId(target).category("FRAUD").reason("bbb") + .idempotencyKey("k-2").build()); + mockMvc.perform(post("/reports").contentType(MediaType.APPLICATION_JSON) + .content(base).header("Authorization", token(me))).andExpect(status().isOk()); + mockMvc.perform(post("/reports").contentType(MediaType.APPLICATION_JSON) + .content(diff).header("Authorization", token(me))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9606)); + } + + // ── 校验与权限 ──────────────────────────────────────── + + @Test + void evidenceNotHttp_returns400_1001() throws Exception { + String body = "{\"targetType\":\"USER\",\"targetUserId\":" + target + + ",\"category\":\"HARASSMENT\",\"reason\":\"x\"," + + "\"evidenceUrls\":[\"javascript:alert(1)\"]}"; + mockMvc.perform(post("/reports").contentType(MediaType.APPLICATION_JSON) + .content(body).header("Authorization", token(me))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1001)); + } + + @Test + @SuppressWarnings("unchecked") + void rateLimited_returns429_9609() throws Exception { + ValueOperations ops = mock(ValueOperations.class); + when(ops.increment(anyString())).thenReturn(11L); + when(redisTemplate.opsForValue()).thenReturn(ops); + String body = body(CreateReportRequest.builder() + .targetType("USER").targetUserId(target).category("SPAM").reason("x").build()); + mockMvc.perform(post("/reports").contentType(MediaType.APPLICATION_JSON) + .content(body).header("Authorization", token(me))) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.code").value(9609)); + } + + @Test + void unauthorized_returns401() throws Exception { + String body = body(CreateReportRequest.builder() + .targetType("USER").targetUserId(target).category("HARASSMENT").reason("x").build()); + mockMvc.perform(post("/reports").contentType(MediaType.APPLICATION_JSON) + .content(body)).andExpect(status().isUnauthorized()); + } + + // ── 列表 / 详情 / 路由顺序 ────────────────────────────── + + @Test + void getDetail_owner_returns200_otherReturns403() throws Exception { + String body = body(CreateReportRequest.builder() + .targetType("USER").targetUserId(target).category("HARASSMENT").reason("x").build()); + String resp = mockMvc.perform(post("/reports").contentType(MediaType.APPLICATION_JSON) + .content(body).header("Authorization", token(me))) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + long id = extractId(resp); + mockMvc.perform(get("/reports/{id}", id).header("Authorization", token(me))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(id)); + mockMvc.perform(get("/reports/{id}", id).header("Authorization", token(other))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + @Test + void listMine_returns200_notDetails() throws Exception { + // 与 /reports/{id} 路由顺序回归:/reports/me 必须命中列表而非把 "me" 当 id + mockMvc.perform(get("/reports/me").header("Authorization", token(me))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items").exists()) + .andExpect(jsonPath("$.data.totalPages").exists()); + } + + private long extractId(String resp) { + int i = resp.indexOf("\"id\":"); + int start = resp.indexOf(':', i) + 1; + int end = resp.indexOf(',', start); + return Long.parseLong(resp.substring(start, end).trim()); + } + + private String body(CreateReportRequest request) { + try { + return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(request); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/safety/ReportPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/safety/ReportPersistenceIntegrationTest.java new file mode 100644 index 0000000..6f26236 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/safety/ReportPersistenceIntegrationTest.java @@ -0,0 +1,184 @@ +package com.mydreamjob.safety; + +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.audit.AuditLogRepository; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.notification.NotificationRepository; +import com.mydreamjob.safety.dto.CreateReportRequest; +import com.mydreamjob.safety.dto.ReportDecisionRequest; +import com.mydreamjob.safety.dto.ReportResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 举报持久化集成测试(V33,H2 MODE=MySQL + Flyway)。验证 active_key 唯一约束、 + * @Version 乐观锁、V33 迁移生效、处置释放 active_key 允许重报、业务数据不删除。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class ReportPersistenceIntegrationTest { + + @Autowired private ReportRepository reportRepository; + @Autowired private ReportDecisionHistoryRepository historyRepository; + @Autowired private BlockRelationRepository blockRepository; + @Autowired private UserRepository userRepository; + @Autowired private ReportService reportService; + @Autowired private NotificationRepository notificationRepository; + @Autowired private AuditLogRepository auditLogRepository; + + @MockBean private RedisTemplate redisTemplate; + + private static final AtomicLong PHONE = new AtomicLong(2200); + + /** 本类本次运行创建的 userId,tearDown 仅删除这些 user,避免触发共享 H2 中跨类 applications FK 违约。 */ + private final List createdUserIds = new ArrayList<>(); + + private Long reporter; + private Long target; + private Long admin; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + reporter = createUser("Reporter"); + target = createUser("Target"); + admin = createUser("Admin"); + } + + @AfterEach + void tearDown() { + historyRepository.deleteAll(); + reportRepository.deleteAll(); + blockRepository.deleteAll(); + notificationRepository.deleteAll(); + auditLogRepository.deleteAll(); + createdUserIds.forEach(userRepository::deleteById); + createdUserIds.clear(); + } + + private Long createUser(String tag) { + Long id = userRepository.save(User.builder() + .phone("138" + String.format("%08d", PHONE.incrementAndGet())) + .nickname(tag).role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)) + .active(true).verified(true).build()).getId(); + createdUserIds.add(id); + return id; + } + + private Report buildReport(String activeKey) { + return Report.builder() + .reporterUserId(reporter) + .targetType("USER").targetUserId(target).reportedUserId(target) + .category("SPAM").reason("重复").activeKey(activeKey) + .status(Report.STATUS_PENDING).build(); + } + + @Test + void v33Migration_applied_tablesUsable() { + // 表存在可写入 + Report saved = reportRepository.save(buildReport("ak-mig")); + Assertions.assertNotNull(saved.getId()); + blockRepository.save(BlockRelation.builder().blockerUserId(reporter) + .blockedUserId(target).build()); + Assertions.assertEquals(1, blockRepository.findByBlockerUserId(reporter).size()); + } + + @Test + void activeKey_unique_secondInsertThrows() { + reportRepository.saveAndFlush(buildReport("ak-same")); + Assertions.assertThrows(DataIntegrityViolationException.class, + () -> reportRepository.saveAndFlush(buildReport("ak-same"))); + } + + @Test + void version_increments_onDecide_andActiveKeyFreed() { + Report r = reportRepository.saveAndFlush(buildReport("ak-vers")); + Long v0 = r.getVersion(); + ReportDecisionRequest req = new ReportDecisionRequest(); + req.setDecision("RESOLVED"); + req.setNote("ok"); + reportService.decide(admin, "ADMIN", r.getId(), req, AuditContext.builder().build()); + Report reloaded = reportRepository.findById(r.getId()).orElseThrow(); + Assertions.assertEquals(Report.STATUS_RESOLVED, reloaded.getStatus()); + Assertions.assertNotNull(reloaded.getVersion()); + Assertions.assertTrue(reloaded.getVersion() > v0); + // 处置后释放 active_key,允许重新举报 + Assertions.assertNull(reloaded.getActiveKey()); + } + + @Test + void resolvedReport_allowsRereport() { + ReportResponse first = reportService.create(reporter, CreateReportRequest.builder() + .targetType("USER").targetUserId(target).category("HARASSMENT").reason("一次") + .idempotencyKey("rr-1").build(), AuditContext.builder().build()); + ReportDecisionRequest req = new ReportDecisionRequest(); + req.setDecision("REJECTED"); + req.setNote("证据不足"); + reportService.decide(admin, "ADMIN", first.getId(), req, AuditContext.builder().build()); + // 处置后可重新提交同一目标同一分类的举报 + ReportResponse second = reportService.create(reporter, CreateReportRequest.builder() + .targetType("USER").targetUserId(target).category("HARASSMENT").reason("再次") + .idempotencyKey("rr-2").build(), AuditContext.builder().build()); + Assertions.assertNotEquals(first.getId(), second.getId()); + Assertions.assertEquals(Report.STATUS_PENDING, second.getStatus()); + } + + @Test + void businessRows_notDeleted() { + Long userBefore = userRepository.count(); + reportService.create(reporter, CreateReportRequest.builder() + .targetType("USER").targetUserId(target).category("HARASSMENT").reason("x") + .build(), AuditContext.builder().build()); + Long userAfter = userRepository.count(); + // 举报不删除既有业务数据 + Assertions.assertEquals(userBefore, userAfter); + } + + @Test + void rateLimited_shouldPersistSecurityAudit() { + // 命中限流:incr 计数 > 上限 → isAllowed=false → 抛 429 + @SuppressWarnings("unchecked") + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(ops.increment(anyString())).thenReturn(11L); + + Assertions.assertThrows(BusinessException.class, () -> reportService.create(reporter, + CreateReportRequest.builder() + .targetType("USER").targetUserId(target).category("HARASSMENT").reason("x") + .build(), AuditContext.builder().build())); + + // 外层事务回滚,但拒绝型安全审计必须在独立事务落库 + long cnt = auditLogRepository.findAll().stream() + .filter(a -> "REPORT_RATE_LIMITED".equals(a.getAction()) + && reporter.equals(a.getUserId())) + .count(); + Assertions.assertTrue(cnt >= 1, "限流拒绝后 REPORT_RATE_LIMITED 审计应落库,实际=" + cnt); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/resources/application-integration-test.yml b/mydreamjob-server/src/test/resources/application-integration-test.yml index 540ba0a..2aa541b 100644 --- a/mydreamjob-server/src/test/resources/application-integration-test.yml +++ b/mydreamjob-server/src/test/resources/application-integration-test.yml @@ -47,6 +47,9 @@ app: resume: storage-dir: ./target/resumes export-dir: ./target/resume-exports + data-export: + dir: ./target/data-exports + file-ttl-hours: 24 jwt: secret: test-jwt-secret-key-for-integration-tests-only-not-for-production expiration-ms: 3600000 diff --git a/product-docs/职来AI求职招聘平台产品与开发实施方案-v3.1.md b/product-docs/职来AI求职招聘平台产品与开发实施方案-v3.1.md index 6bd33b1..af1a16d 100644 --- a/product-docs/职来AI求职招聘平台产品与开发实施方案-v3.1.md +++ b/product-docs/职来AI求职招聘平台产品与开发实施方案-v3.1.md @@ -75,7 +75,7 @@ | ------- | ---------------- | --- | ---------------------------- | | FR-0101 | 手机号注册与验证码登录 | P0 | 验证码 60 秒有效,同一手机号 5 分钟内限发 3 次 | | FR-0102 | 注册时选定角色(求职者/招聘者) | P0 | 单账号单角色,注册后不可切换;角色决定数据权限 | -| FR-0103 | 账号注销 | P0 | 7 天冷静期,到期删除账号及关联数据(合规底线,不可砍) | +| FR-0103 | 账号注销 | P0 | 30 天冷静期,到期后脱敏账号 PII,并保留业务记录与审计轨迹(合规底线,不可砍) | | FR-0104 | 第三方登录(微信) | P1 | 与手机号绑定 | | FR-0105 | 双角色切换 | P1 | 同账号双角色切换,数据隔离 | | FR-0106 | 数据导出 | P2 | 内测期由客服人工处理,公测前实现自助导出 | diff --git a/product-docs/职来AI求职招聘平台产品与开发实施方案.md b/product-docs/职来AI求职招聘平台产品与开发实施方案.md index 99ae7a3..5744ead 100644 --- a/product-docs/职来AI求职招聘平台产品与开发实施方案.md +++ b/product-docs/职来AI求职招聘平台产品与开发实施方案.md @@ -49,7 +49,7 @@ | FR-0101 | 手机号注册与验证码登录 | P0 | 验证码 60 秒有效,同一手机号 5 分钟内限发 3 次 | | FR-0102 | 求职者/招聘者角色切换 | P0 | 同一账号可双角色切换,数据隔离 | | FR-0103 | 第三方登录(微信) | P1 | 与手机号绑定,支持快速登录 | -| FR-0104 | 账号注销与数据导出 | P1 | 注销流程 7 天冷静期,数据可导出 JSON | +| FR-0104 | 账号注销与数据导出 | P1 | 注销流程 30 天冷静期;到期后脱敏账号 PII 并保留业务记录与审计轨迹;数据可导出 JSON | **用户故事:** > 作为一名应届生,我希望能用手机号快速注册登录,这样我可以立即开始使用平台而无需复杂的注册流程。