forked from baskbuddy/zhangshun
38 changed files with 2698 additions and 17 deletions
@ -0,0 +1,111 @@ |
|||
# V40 企业评价管理与官方回复 API |
|||
|
|||
## 1. 范围 |
|||
|
|||
V40 为企业提供自有岗位评价查询、官方回复提交和管理员确定性预审。评价本身仍由既有学生评价模块维护;本模块不提供评价删除、隐藏、编辑、申诉、通知或运营统计。 |
|||
|
|||
Flyway 迁移为 `V38__company_review_replies.sql`(功能版本 V40),新增 `company_review_replies` 与 `company_review_reply_history` 两表,不修改 V1–V37。所有身份从 JWT subject 获取,不能从请求体或查询参数注入 `companyId`、`studentUserId` 或 `ownerUserId`。 |
|||
|
|||
## 2. 接口 |
|||
|
|||
| 方法 | 路径 | 权限 | |
|||
| --- | --- | --- | |
|||
| GET | `/companies/me/job-reviews` | COMPANY,且仅返回本人公司岗位 | |
|||
| GET | `/companies/me/job-reviews/{reviewId}` | COMPANY,且必须属于本人公司 | |
|||
| POST | `/companies/me/job-reviews/{reviewId}/reply` | 所属 COMPANY | |
|||
| PUT | `/companies/me/job-reviews/{reviewId}/reply` | 所属 COMPANY,仅 REJECTED 回复 | |
|||
| GET | `/admin/review-replies/pending` | ADMIN | |
|||
| POST | `/admin/review-replies/{replyId}/decision` | ADMIN | |
|||
|
|||
未登录返回 401;角色不匹配或跨公司访问返回 403/2002。V40 不增加其他公开路径、ErrorCode 或 feature flag。 |
|||
|
|||
## 3. 回复生命周期 |
|||
|
|||
```text |
|||
无回复 -> 企业创建 -> AUTO_APPROVED(公开) |
|||
-> PENDING -> APPROVED(公开) |
|||
-> REJECTED -> 企业 PUT 修订 -> AUTO_APPROVED/PENDING |
|||
``` |
|||
|
|||
每条评价只有一个当前回复。`AUTO_APPROVED`、`APPROVED` 不可修改或删除;`REJECTED` 必须带 `expectedVersion` 修订。管理员决策使用 `version` 条件更新,并发时只有一个请求成功。 |
|||
|
|||
## 4. 请求与响应 |
|||
|
|||
创建: |
|||
|
|||
```json |
|||
{ |
|||
"content": "感谢您的反馈,我们会持续优化招聘流程。", |
|||
"idempotencyKey": "合法 UUID" |
|||
} |
|||
``` |
|||
|
|||
正文去首尾空白后长度为 1–500;幂等键必须为 UUID 且不超过 64 字符。同企业、同评价、同正文的重放返回原回复;同幂等键但评价或正文不同返回 `409/10106`。 |
|||
|
|||
修订: |
|||
|
|||
```json |
|||
{ |
|||
"content": "修订后的回复内容", |
|||
"expectedVersion": 1 |
|||
} |
|||
``` |
|||
|
|||
管理员决策: |
|||
|
|||
```json |
|||
{"decision":"APPROVE","reason":null} |
|||
``` |
|||
|
|||
或: |
|||
|
|||
```json |
|||
{"decision":"REJECT","reason":"请移除不适合公开的联系方式。"} |
|||
``` |
|||
|
|||
企业响应白名单包括评价文本、星级、标签、维度聚合、岗位标题、回复正文/状态/版本/更新时间及驳回的人类说明;不返回 `studentUserId`、学生姓名、手机号、邮箱、IP、UA、内容哈希、评价审核内部原因、`moderationScore`、`reasonCodes` 或管理员 ID。 |
|||
|
|||
管理员待审响应可包含岗位/公司、评价和回复正文、风险等级及安全原因码,但不包含学生联系方式、IP/UA 明文或凭据。 |
|||
|
|||
## 5. 公开评价回复 |
|||
|
|||
既有 `GET /jobs/{jobId}/reviews` 和 `GET /jobs/{jobId}/reviews/{reviewId}` 保持不变。公开载荷移除 `studentUserId`,增加固定 `reviewerLabel: "匿名求职者"`。当评价为公开状态且回复为 `AUTO_APPROVED` 或 `APPROVED` 时,返回: |
|||
|
|||
```json |
|||
{"officialReply":{"content":"感谢反馈。","repliedAt":"2026-09-04T10:05:00"}} |
|||
``` |
|||
|
|||
`officialReply` 不含 replyId、companyId、status、version、原因码或审核人信息;`PENDING`/`REJECTED` 回复不公开。公开列表先分页取评价,再批量取回复和维度分,禁止逐条查询。 |
|||
|
|||
## 6. 当前验收状态 |
|||
|
|||
V40 专项与全量自动化测试已通过(全量 1445/1445,Failures=0、Errors=0、Skipped=0),`mvn clean verify` 已通过,静态与运行时 OpenAPI 均为 159 paths / 196 operations。真实 MySQL/Docker 黑盒已通过:Flyway V38 success=1、主链路/权限/幂等/状态机/并发创建与审核/脱敏/队列终态全部通过,汇总 `PASS=62 FAIL=0 SKIP=0`。V40 不写 `candidate_dimension_scores`,未提交 Git。 |
|||
|
|||
## 7. 错误码 |
|||
|
|||
| 错误码 | HTTP | 含义 | |
|||
| --- | --- | --- | |
|||
| 10101 | 404 | 回复不存在(或按归属策略隐藏) | |
|||
| 10102 | 409 | 评价已有当前回复 | |
|||
| 10103 | 409 | 回复状态不允许当前操作 | |
|||
| 10104 | 409 | 乐观锁/条件更新冲突 | |
|||
| 10105 | 400 | 回复正文或幂等键非法 | |
|||
| 10106 | 409 | 幂等键对应的请求内容冲突 | |
|||
| 10107 | 400 | 非法决策或驳回原因缺失 | |
|||
|
|||
## 8. 审计与数据安全 |
|||
|
|||
成功状态变化写入既有 `audit_logs` 和回复历史。详情只含 `reviewId`、`replyId`、`status`、`requestId` 等白名单字段,不写回复正文、评价正文、学生 ID、联系方式、IP/UA、哈希、风控原文、token、password 或 secret。V40 不写 `candidate_dimension_scores`。 |
|||
|
|||
企业列表和管理员待审列表均采用批量加载岗位、维度分和回复,测试断言查询次数不随条目数线性增长。 |
|||
|
|||
## 9. 验收 |
|||
|
|||
```powershell |
|||
mvn -Dtest="*Review*Test,*CompanyReview*Test,*AdminReview*Test" test |
|||
mvn test |
|||
mvn clean verify |
|||
python docs/verify_openapi.py |
|||
``` |
|||
|
|||
本轮自动化验收不启动/停止 Docker;真实 MySQL/Docker 黑盒须单独执行并如实记录。 |
|||
@ -0,0 +1,357 @@ |
|||
# V40 企业评价管理与官方回复:详细设计 |
|||
|
|||
> 状态:实现已完成,专项/全量自动化测试、`mvn clean verify` 与真实 MySQL/Docker 黑盒均已通过;待建立 Git 检查点。 |
|||
> |
|||
> 本文定义并约束 V40 的后端交付边界。实现不会修改已有 V17/V18 的学生评价、管理员评价审核与防刷语义;不会新增测评、评分、推荐接口。 |
|||
|
|||
## 1. 目标、范围与现状 |
|||
|
|||
### 1.1 交付目标 |
|||
|
|||
让已认证企业能够: |
|||
|
|||
1. 查看**自己名下岗位**的评价及处理状态; |
|||
2. 对每条评价提交一条官方回复; |
|||
3. 在回复被拦截或管理员驳回后,查看安全的处理状态并修订后重新提交; |
|||
4. 让公众仅在评价和官方回复都满足公开条件时看到该回复。 |
|||
|
|||
企业的“管理”在 V40 中是查看、筛选、回复和查看回复审核状态;**不是**删除、隐藏、改写或自行下架学生评价。企业针对评价真实性的申诉、投诉工单、运营统计和通知策略不纳入本版,留给后续独立模块。 |
|||
|
|||
### 1.2 已有能力与约束 |
|||
|
|||
| 项目 | 当前事实 | V40 处理 | |
|||
| --- | --- | --- | |
|||
| 学生岗位评价 | `job_reviews`,同一学生对同一岗位唯一;已有评分、标签、八维评分 | 复用,不重建 | |
|||
| 评价审核 | V18:`AUTO_APPROVED`、`PENDING`、`APPROVED`、`REJECTED`,管理员已有评价审核接口 | 保持原链路;新增“回复审核”并与评价审核分开 | |
|||
| 企业权限 | 角色为 `COMPANY`;岗位归属可按公司 owner 与岗位 companyId 校验 | 所有企业接口均由 JWT 身份和岗位归属决定 | |
|||
| 审计 | 已有 `audit_logs` | 对回复生命周期写白名单审计 | |
|||
| 当前迁移 | 最新为 `V37__job_recommendation_profiles.sql` | V40 若编码,新增 `V38__company_review_replies.sql` | |
|||
| 当前 OpenAPI 基线 | V39 后为 154 paths / 190 operations | 预计增至 159 paths / 196 operations,编码后实测为准 | |
|||
|
|||
### 1.3 明确不做 |
|||
|
|||
- 不新增学生写评价、改评价、删评价接口; |
|||
- 不让企业删除、隐藏、编辑评分或审核学生评价; |
|||
- 不把评价人 `studentUserId`、手机号、邮箱、IP、User-Agent、内容哈希、风控分或内部原因返回给企业; |
|||
- 不新增企业查看测评、候选人隐私数据或候选评分明细的能力; |
|||
- 不接入 AI 自动生成回复,不声称现有确定性规则为 AI; |
|||
- 不做评价申诉/争议工单、回复点赞、企业客服工单、通知推送或数据看板; |
|||
- 不改 V35–V39 迁移及语义,不写 `candidate_dimension_scores`。 |
|||
|
|||
## 2. 产品规则与关键决策 |
|||
|
|||
### 2.1 不可变规则 |
|||
|
|||
1. 评价的作者始终是学生;企业对评价没有写、删、隐藏权限。 |
|||
2. 企业只能操作其本人拥有公司下的岗位评价;跨公司访问统一返回 `403/2002`,不通过“是否存在”泄露其他公司数据。 |
|||
3. 每条评价最多存在一条当前官方回复,且回复归属由评价所在岗位的公司确定,客户端不得传 `companyId`、`jobId` 或评价作者 ID 来决定归属。 |
|||
4. 一条公开评价只在官方回复为 `AUTO_APPROVED` 或 `APPROVED` 时返回 `officialReply`。`PENDING`、`REJECTED` 及内部审核信息绝不出现在公开接口。 |
|||
5. 企业不可删除官方回复。被 `APPROVED` 或 `AUTO_APPROVED` 的回复不可编辑;被 `REJECTED` 的回复可使用乐观锁修订并重新进入预审。 |
|||
|
|||
### 2.2 回复状态机 |
|||
|
|||
```text |
|||
无回复 |
|||
└─ 企业创建 ─→ AUTO_APPROVED ───────────────→ 公众可见(终态) |
|||
或 |
|||
PENDING ── 管理员同意 ───────→ APPROVED → 公众可见(终态) |
|||
└─ 管理员驳回 ─────→ REJECTED |
|||
└─ 企业修订并重提 ─→ AUTO_APPROVED / PENDING |
|||
``` |
|||
|
|||
- 预审无风险:`AUTO_APPROVED`,可随公开评价直接展示。 |
|||
- 命中敏感词、联系方式、URL、异常重复字符等已有确定性规则:`PENDING`,仅管理员可审核。 |
|||
- `PENDING` 只能由 `ADMIN` 变更为 `APPROVED` 或 `REJECTED`。 |
|||
- `REJECTED` 的公司可提交修订版本;版本号递增、审核痕迹保留。 |
|||
- `AUTO_APPROVED` 与 `APPROVED` 为对外稳定文本。若未来需要更正,应另做“公开回复修订/版本化”需求,不能在 V40 中通过覆盖原文实现。 |
|||
|
|||
### 2.3 公开身份脱敏(V40 必做修正) |
|||
|
|||
现有 `JobReviewService.toView(...)` 会在公开岗位评价载荷中输出 `studentUserId`。该字段会使企业或任意访客将评价与学生内部主键关联,不满足“评价对对方脱敏”的边界。 |
|||
|
|||
V40 编码必须将公开评价 DTO 改为白名单响应,移除 `studentUserId`,使用固定的 `reviewerLabel: "匿名求职者"`(或不返回该字段)。学生本人视图可保留“是否为我创建”的布尔信息,但不得把任何学生 ID 交给企业、公众或其他学生。此项是对已有公开契约的隐私修复;前端必须在联调时同步移除对 `studentUserId` 的依赖。 |
|||
|
|||
## 3. 数据模型与 Flyway |
|||
|
|||
### 3.1 迁移 |
|||
|
|||
新增 `V38__company_review_replies.sql`。不改已执行的 V1–V37,也不通过 `ddl-auto` 补表。 |
|||
|
|||
### 3.2 `company_review_replies` |
|||
|
|||
| 列 | 类型/约束 | 说明 | |
|||
| --- | --- | --- | |
|||
| `id` | BIGINT PK | 回复 ID | |
|||
| `review_id` | BIGINT NOT NULL, UNIQUE, FK → `job_reviews(id)` ON DELETE CASCADE | 一条评价仅一个当前官方回复 | |
|||
| `company_id` | BIGINT NOT NULL, FK → `companies(id)` | 由服务端从岗位归属推导 | |
|||
| `content` | VARCHAR(500) NOT NULL | 官方回复正文,1–500 字符 | |
|||
| `moderation_status` | VARCHAR(20) NOT NULL | `AUTO_APPROVED` / `PENDING` / `APPROVED` / `REJECTED` | |
|||
| `moderation_reason` | VARCHAR(500) NULL | 内部安全原因码 JSON;不公开给企业或公众 | |
|||
| `moderation_score` | DECIMAL(5,2) NULL | 内部风控分,不公开 | |
|||
| `risk_level` | VARCHAR(20) NULL | 内部风险等级,不公开 | |
|||
| `reviewed_by_user_id` | BIGINT NULL FK → `users(id)` | 管理员审核人 | |
|||
| `reviewed_at` | DATETIME NULL | 最后一次管理员审核时间 | |
|||
| `version` | BIGINT NOT NULL DEFAULT 0 | JPA `@Version`;企业修订携带 `expectedVersion` | |
|||
| `created_at` / `updated_at` | DATETIME | 审计时间 | |
|||
|
|||
约束与索引: |
|||
|
|||
- `UNIQUE(review_id)`; |
|||
- `CHECK(moderation_status IN ('AUTO_APPROVED','PENDING','APPROVED','REJECTED'))`; |
|||
- `INDEX(company_id, moderation_status, updated_at)` 支撑企业列表; |
|||
- `INDEX(moderation_status, created_at)` 支撑管理员待审列表。 |
|||
|
|||
### 3.3 `company_review_reply_history` |
|||
|
|||
保留回复生命周期而非覆盖历史。 |
|||
|
|||
| 列 | 类型/约束 | 说明 | |
|||
| --- | --- | --- | |
|||
| `id` | BIGINT PK | 历史记录 ID | |
|||
| `reply_id` | BIGINT NOT NULL FK → reply ON DELETE CASCADE | 对应回复 | |
|||
| `action` | VARCHAR(30) NOT NULL | `CREATED`、`AUTO_APPROVED`、`PENDING`、`RESUBMITTED`、`APPROVED`、`REJECTED` | |
|||
| `actor_user_id` | BIGINT NULL | 操作者;管理端可审计,企业/公众不返回 | |
|||
| `reason_codes` | VARCHAR(500) NULL | 安全原因码,不含敏感词原文 | |
|||
| `human_reason` | VARCHAR(500) NULL | 管理员驳回说明,仅向该公司展示,禁止包含评价人身份信息 | |
|||
| `created_at` | DATETIME NOT NULL | 时间 | |
|||
|
|||
索引:`INDEX(reply_id, created_at)`。历史表不保存正文副本、IP、UA、学生 ID、题目或候选人数据。 |
|||
|
|||
### 3.4 审计日志 |
|||
|
|||
写入既有 `audit_logs`,详情严格白名单: |
|||
|
|||
```json |
|||
{ |
|||
"reviewId": 120, |
|||
"replyId": 42, |
|||
"jobId": 9, |
|||
"status": "PENDING", |
|||
"requestId": "..." |
|||
} |
|||
``` |
|||
|
|||
动作:`COMPANY_REVIEW_REPLY_CREATED`、`COMPANY_REVIEW_REPLY_RESUBMITTED`、`REVIEW_REPLY_AUTO_APPROVED`、`REVIEW_REPLY_APPROVED`、`REVIEW_REPLY_REJECTED`。详情不得包含正文、评价作者、手机号、邮箱、IP、UA、哈希、token 或审核规则原文。 |
|||
|
|||
## 4. 接口契约 |
|||
|
|||
所有身份均从 `SecurityContext` / JWT subject 获取,不接收 `userId`、`companyId`、`studentUserId` 等身份参数。统一响应为既有 `ApiResponse{code,message,data,requestId,timestamp}`;分页 `page` 从 0 开始、`size` 范围 1–50。 |
|||
|
|||
### 4.1 企业端(仅 `COMPANY`) |
|||
|
|||
| 方法 | 路径 | 说明 | |
|||
| --- | --- | --- | |
|||
| GET | `/companies/me/job-reviews` | 分页查看本人公司岗位评价;支持 `jobId`、`rating`、`replyStatus`、`createdFrom`、`createdTo` 筛选 | |
|||
| GET | `/companies/me/job-reviews/{reviewId}` | 查看一条本人公司岗位评价及本人官方回复状态 | |
|||
| POST | `/companies/me/job-reviews/{reviewId}/reply` | 创建唯一官方回复;请求需 `content`、`idempotencyKey` | |
|||
| PUT | `/companies/me/job-reviews/{reviewId}/reply` | 仅允许修订 `REJECTED` 回复;请求需 `content`、`expectedVersion` | |
|||
|
|||
企业列表/详情最小响应: |
|||
|
|||
```json |
|||
{ |
|||
"reviewId": 120, |
|||
"job": { "id": 9, "title": "后端工程师" }, |
|||
"rating": 4, |
|||
"content": "岗位描述较清晰。", |
|||
"tags": ["成长空间"], |
|||
"dimensionScores": { "SALARY": 4, "WORK_LIFE_BALANCE": 3 }, |
|||
"reviewCreatedAt": "2026-09-04T10:00:00", |
|||
"reply": { |
|||
"id": 42, |
|||
"content": "感谢反馈,我们会继续优化沟通。", |
|||
"status": "PENDING", |
|||
"version": 0, |
|||
"updatedAt": "2026-09-04T10:05:00", |
|||
"rejectionReason": null |
|||
} |
|||
} |
|||
``` |
|||
|
|||
禁止返回:`studentUserId`、评价作者显示名、手机号、邮箱、IP、UA、内容哈希、审核分、评价审核内部原因、回复预审内部原因、管理员 ID。 |
|||
|
|||
`idempotencyKey` 必须为 UUID、最多 64 字符。同键同评价同正文返回已有回复;同键不同评价或正文返回 `409/10106`。创建时已有回复:`PENDING` / `AUTO_APPROVED` / `APPROVED` 返回 `409/10102`;`REJECTED` 必须走 PUT 重提。 |
|||
|
|||
### 4.2 管理员端(仅 `ADMIN`) |
|||
|
|||
| 方法 | 路径 | 说明 | |
|||
| --- | --- | --- | |
|||
| GET | `/admin/review-replies/pending` | 分页查看 `PENDING` 官方回复;可按 `companyId`、`jobId`、`riskLevel`、时间筛选 | |
|||
| POST | `/admin/review-replies/{replyId}/decision` | `APPROVE` / `REJECT`;仅 `PENDING` 可决策;驳回时 `reason` 必填,1–500 字符 | |
|||
|
|||
管理员响应可看安全原因码、风险等级、岗位/公司信息与回复正文,用于审核;仍不得返回评价学生的联系电话、邮箱、IP/UA 明文或密码/JWT。管理员决策重复提交或非 `PENDING` 状态返回冲突。 |
|||
|
|||
### 4.3 现有公开评价接口的兼容扩展 |
|||
|
|||
既有以下接口路径不新增操作: |
|||
|
|||
- `GET /jobs/{jobId}/reviews` |
|||
- `GET /jobs/{jobId}/reviews/{reviewId}` |
|||
|
|||
每条公开评价可新增可选 `officialReply`: |
|||
|
|||
```json |
|||
{ |
|||
"content": "感谢反馈。", |
|||
"repliedAt": "2026-09-04T10:05:00" |
|||
} |
|||
``` |
|||
|
|||
仅当评价本身为 `PUBLISHED` 且评价审核状态为 `AUTO_APPROVED` / `APPROVED`,并且回复为 `AUTO_APPROVED` / `APPROVED` 时出现。该对象没有 `replyId`、`companyId`、审核人、状态、原因码和版本字段。无公开回复时字段省略或为 `null`,前端须兼容两种情况。 |
|||
|
|||
### 4.4 权限矩阵 |
|||
|
|||
| 行为 | STUDENT | COMPANY(所属) | COMPANY(非所属) | ADMIN | 匿名 | |
|||
| --- | --- | --- | --- | --- | --- | |
|||
| 公开评价/公开回复 | 可读 | 可读 | 可读 | 可读 | 可读 | |
|||
| 企业评价管理列表/详情 | 403/2002 | 可读 | 403/2002 | 403/2002 | 401 | |
|||
| 创建/修订官方回复 | 403/2002 | 可写 | 403/2002 | 403/2002 | 401 | |
|||
| 待审官方回复/决策 | 403/2002 | 403/2002 | 403/2002 | 可读/可决策 | 401 | |
|||
| 删除/隐藏学生评价 | 不新增该权限 | 永不允许 | 永不允许 | 继续沿用既有审核而非删除 | 不适用 | |
|||
|
|||
## 5. 服务、查询与一致性设计 |
|||
|
|||
### 5.1 推荐组件 |
|||
|
|||
- `CompanyReviewManagementService`:企业列表、详情、归属校验; |
|||
- `CompanyReviewReplyService`:创建、预审、修订、状态机、审计; |
|||
- `AdminReviewReplyService`:待审列表与管理员决策; |
|||
- `CompanyReviewReplyModerationService`:复用现有确定性文本安全规则的公共部分,不复制 Controller 中的正则或把回复伪称为 AI 审核; |
|||
- `CompanyReviewReply`、`CompanyReviewReplyHistory` 及对应 Repository/DTO/Controller。 |
|||
|
|||
### 5.2 企业归属校验 |
|||
|
|||
服务端按当前用户找到企业,再由 `review.jobId → job.companyId` 比对。所有按 `reviewId` 的企业操作先检查岗位归属;不把“评价存在但不属于你”与“不存在”区分为可枚举信息。禁止以请求内 `companyId` 决定查询范围。 |
|||
|
|||
### 5.3 防 N+1 查询 |
|||
|
|||
企业列表采用“岗位评价页 + 批量补齐”策略: |
|||
|
|||
1. 分页查询当前公司岗位的评价,并 join/批量取得岗位标题; |
|||
2. 对本页 `reviewId` 一次性查询 `job_review_dimension_scores`; |
|||
3. 对本页 `reviewId` 一次性查询官方回复; |
|||
4. 在内存按 ID 映射组装。 |
|||
|
|||
不得逐条调用 `findById`、逐条查询维度分或回复。管理员待审列表同样使用 reply 与岗位/公司 `JOIN FETCH` 或投影查询。编码测试应证明查询次数与页面条目数无关。 |
|||
|
|||
### 5.4 并发与幂等 |
|||
|
|||
- 创建回复:数据库 `UNIQUE(review_id)` 是最终防线;服务先处理同 `idempotencyKey` 重放,再依赖唯一键处理并发创建。 |
|||
- 修订回复:请求带 `expectedVersion`,实体使用 `@Version`;过期版本抛 `409/10104`。 |
|||
- 管理员决策:条件更新 `WHERE id=? AND moderation_status='PENDING' AND version=?`,影响行数为 0 时返回 `409/10103` 或 `10104`;仅成功事务生成一次历史和审计。 |
|||
- 任何唯一键或乐观锁异常都不得映射为 500 或泛化为错误的“已存在”。 |
|||
|
|||
## 6. 审核、内容安全和异常语义 |
|||
|
|||
### 6.1 文本规则 |
|||
|
|||
回复正文限制 1–500 字符,拒绝空白文本。复用已有规则中可泛化的:敏感词、手机号、QQ/微信引流、URL、异常重复字符。对企业回复不使用“学生快速提交”“跨学生重复评价”“学生批量刷评”一类依赖学生行为的规则。 |
|||
|
|||
规则命中时内容可以保存为 `PENDING`,但不得公开;管理员作出决定后才变为公开或驳回。原因码仅给管理员;企业在被驳回时只看到管理员填写的安全说明,不展示敏感词、内部阈值、风控分。 |
|||
|
|||
### 6.2 错误码(拟新增 101xx) |
|||
|
|||
| 错误码 | HTTP | 含义 | |
|||
| --- | --- | --- | |
|||
| 10101 `REVIEW_REPLY_NOT_FOUND` | 404 | 回复不存在,或不属于本企业时按权限策略处理 | |
|||
| 10102 `REVIEW_REPLY_ALREADY_EXISTS` | 409 | 评价已有当前官方回复 | |
|||
| 10103 `REVIEW_REPLY_INVALID_STATE` | 409 | 状态不允许创建修订/决策 | |
|||
| 10104 `REVIEW_REPLY_CONCURRENT_UPDATE` | 409 | 乐观锁或条件更新冲突 | |
|||
| 10105 `REVIEW_REPLY_CONTENT_INVALID` | 400 | 空白、超长或不符合字段校验 | |
|||
| 10106 `REVIEW_REPLY_IDEMPOTENCY_CONFLICT` | 409 | 同幂等键但评价/正文不同 | |
|||
| 10107 `REVIEW_REPLY_DECISION_INVALID` | 400 | 非法审核决策或驳回原因缺失 | |
|||
|
|||
角色不匹配继续使用既有 `FORBIDDEN/2002`,未登录继续使用 `UNAUTHORIZED/401`,不增加重复错误码。 |
|||
|
|||
## 7. DTO 白名单 |
|||
|
|||
| 载荷 | 可以返回 | 绝不返回 | |
|||
| --- | --- | --- | |
|||
| 企业评价列表/详情 | 评价文本、星级、标签、维度聚合、岗位公开名称、时间、该企业自身回复状态 | 学生 ID/联系方式、IP/UA、哈希、评价审核内部信息 | |
|||
| 企业回复 | 内容、状态、版本、更新时间、管理员驳回说明 | 审核人 ID、内部原因码、风控分、内部请求 ID | |
|||
| 管理员待审回复 | 回复文本、公司/岗位、风险等级与安全原因码 | 学生联系信息、IP/UA 明文、密码、JWT | |
|||
| 公开评价回复 | `content`、`repliedAt` | ID、公司 ID、状态、版本、审核细节、所有评价人信息 | |
|||
|
|||
任何 response DTO 禁止用 Entity 直接序列化;使用显式 DTO 或白名单 `Map`。对 `officialReply`、企业列表、管理员列表分别写 JSON 序列化脱敏断言。 |
|||
|
|||
## 8. 预期 OpenAPI 与前端协作 |
|||
|
|||
预计新增 5 paths / 6 operations: |
|||
|
|||
```text |
|||
GET /companies/me/job-reviews |
|||
GET /companies/me/job-reviews/{reviewId} |
|||
POST /companies/me/job-reviews/{reviewId}/reply |
|||
PUT /companies/me/job-reviews/{reviewId}/reply |
|||
GET /admin/review-replies/pending |
|||
POST /admin/review-replies/{replyId}/decision |
|||
``` |
|||
|
|||
预计 OpenAPI:154/190 → **159 paths / 196 operations**。实际编码后必须由运行时 `/v3/api-docs` 与 `docs/openapi-rc.json` 对账,`python docs/verify_openapi.py` 通过后才可记录最终数字。 |
|||
|
|||
前端需要: |
|||
|
|||
1. 企业侧支持评价筛选、无回复/待审/已公开/已驳回状态; |
|||
2. 已驳回回复展示安全说明与“修订后重新提交”入口; |
|||
3. 公开岗位评价页仅在 `officialReply` 存在时展示“企业官方回复”; |
|||
4. 移除任何对公开 `studentUserId` 的依赖; |
|||
5. 不把企业端的审核状态、管理员页面或内部 reason codes 暴露给公众。 |
|||
|
|||
## 9. 测试与验收计划 |
|||
|
|||
### 9.1 单元测试 |
|||
|
|||
- 回复状态机:安全文本自动通过、风险文本待审、待审审批/驳回、仅驳回可重提、公开终态不可改; |
|||
- 内容校验:空白、501 字符、手机号/URL/敏感词等; |
|||
- 幂等:同键同体返回同一回复,同键异体 `10106`; |
|||
- 乐观锁和并发创建:同评价并发只成功一条,不出现 500; |
|||
- 脱敏 mapper:企业/公开/管理员三个视图字段严格不同。 |
|||
|
|||
### 9.2 MockMvc 与权限测试 |
|||
|
|||
- COMPANY 可访问所属岗位评价并创建回复; |
|||
- STUDENT、ADMIN、未归属 COMPANY、匿名分别获得既定 403/401; |
|||
- 企业没有 DELETE 评价/回复的路由与权限; |
|||
- PUBLIC 仅看见审核通过的 `officialReply`; |
|||
- 新旧公开评价 JSON 均不含 `studentUserId`、联系方式、内部审核字段。 |
|||
|
|||
### 9.3 H2 持久化测试 |
|||
|
|||
- V38 迁移可应用,FK、唯一约束、CHECK、`@Version` 生效; |
|||
- 评价删除时回复与历史级联删除; |
|||
- 企业列表分页、批量维度分/回复加载,验证无 N+1; |
|||
- 管理员并发决策仅一条历史与一条审计落库; |
|||
- V17/V18 的学生评价审核与汇总回归通过。 |
|||
|
|||
### 9.4 Docker MySQL 黑盒 |
|||
|
|||
1. 学生创建公开评价 → 所属企业列表/详情可见但无评价者 ID; |
|||
2. 企业创建安全回复 → 公开评价出现 `officialReply`; |
|||
3. 企业创建风险回复 → 公众不可见,管理员待审、批准/驳回链路正确; |
|||
4. 驳回后企业修订可重提;公开后不可覆盖或删除; |
|||
5. 非归属企业、STUDENT、ADMIN、匿名越权验证; |
|||
6. 同一评价并发创建、同键重试、管理员并发决策逐请求校验 HTTP 状态/业务码,无 500; |
|||
7. `audit_logs` 只含白名单详情,不含正文、评价人或凭据;RabbitMQ 队列终态存在且 ready/unacked 均为 0; |
|||
8. 回归 V17/V18 公开评价、V33 举报屏蔽、V34 求职进度、V35/V36 测评、V37 风控、V38 评分、V39 推荐关键接口。 |
|||
|
|||
关闭门槛:专项测试与 `mvn clean verify` 全绿、OpenAPI 对账通过、真实 MySQL/Docker 黑盒 `FAIL=0`,关键权限与脱敏项不得 `SKIP`。 |
|||
|
|||
## 10. 回滚与兼容 |
|||
|
|||
- V40 只增加两张回复表、六个操作与公开响应的可选 `officialReply`;不改变学生评价的评分、审核或删除语义。 |
|||
- 回滚采用新的前向迁移停用/删除 V40 回复表,禁止修改已执行的 V38;生产执行前另写并评审回滚迁移。 |
|||
- 若企业回复功能需临时关闭,服务层应拒绝新建/重提而保留既有公开回复可读;是否加入独立 feature flag 需在编码前确认,默认不增加以免无业务需求引入额外配置。 |
|||
|
|||
## 11. 已确认的编码边界(2026-09-04) |
|||
|
|||
负责人已确认以下方案,编码必须以此为准: |
|||
|
|||
1. 采用 `V38__company_review_replies.sql`,新增回复与回复历史两张表; |
|||
2. 每条评价仅有一条当前官方回复;公开后不可覆盖,驳回后可修订重提; |
|||
3. 使用确定性预审:无风险自动公开,风险回复转管理员审核; |
|||
4. V40 立即移除公开评价载荷中的 `studentUserId`,关闭既有隐私泄露; |
|||
5. 本版不做企业申诉/投诉评价、自动通知、运营统计或公开回复编辑历史; |
|||
6. 接口范围锁定为 5 paths / 6 operations,OpenAPI 预计 159 paths / 196 operations; |
|||
7. 不新增企业回复独立 feature flag。 |
|||
|
|||
V40 编码、测试、OpenAPI 更新与真实 MySQL/Docker 黑盒验收均已完成(`PASS=62 FAIL=0 SKIP=0`);仍须遵守模块完成后再建立独立 Git 检查点的治理要求。 |
|||
@ -0,0 +1,78 @@ |
|||
package com.mydreamjob.review; |
|||
|
|||
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.review.dto.AdminReviewReplyPageResponse; |
|||
import com.mydreamjob.review.dto.AdminReviewReplyResponse; |
|||
import com.mydreamjob.review.dto.ReviewReplyDecisionRequest; |
|||
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.format.annotation.DateTimeFormat; |
|||
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.time.LocalDateTime; |
|||
|
|||
@Tag(name = "官方回复审核", description = "管理员审核企业官方回复") |
|||
@RestController |
|||
@RequiredArgsConstructor |
|||
public class AdminReviewReplyController { |
|||
|
|||
private final AdminReviewReplyService adminService; |
|||
|
|||
@Operation(summary = "待审官方回复", description = "仅 ADMIN;分页查看风险回复") |
|||
@GetMapping("/admin/review-replies/pending") |
|||
public ResponseEntity<ApiResponse<AdminReviewReplyPageResponse>> pending( |
|||
@RequestParam(required = false) Long companyId, |
|||
@RequestParam(required = false) Long jobId, |
|||
@RequestParam(required = false) String riskLevel, |
|||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) |
|||
LocalDateTime createdFrom, |
|||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) |
|||
LocalDateTime createdTo, |
|||
@RequestParam(defaultValue = "0") int page, |
|||
@RequestParam(defaultValue = "20") int size, |
|||
HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
return ResponseEntity.ok(ApiResponse.ok(adminService.listPending(userId, companyId, jobId, riskLevel, |
|||
createdFrom, createdTo, page, size), requestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "官方回复审核决策", description = "仅 ADMIN;PENDING 回复可 APPROVE 或 REJECT") |
|||
@PostMapping("/admin/review-replies/{replyId}/decision") |
|||
public ResponseEntity<ApiResponse<AdminReviewReplyResponse>> decide( |
|||
@PathVariable Long replyId, |
|||
@Valid @RequestBody ReviewReplyDecisionRequest requestBody, |
|||
HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
return ResponseEntity.ok(ApiResponse.ok(adminService.decide(userId, replyId, requestBody, |
|||
AuditContext.from(request)), requestId(request))); |
|||
} |
|||
|
|||
private Long resolveUserId() { |
|||
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); |
|||
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getName())) |
|||
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
|||
try { |
|||
return Long.valueOf(auth.getName()); |
|||
} catch (NumberFormatException ex) { |
|||
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
|||
} |
|||
} |
|||
|
|||
private String requestId(HttpServletRequest request) { |
|||
return request.getHeader("X-Request-Id"); |
|||
} |
|||
} |
|||
@ -0,0 +1,204 @@ |
|||
package com.mydreamjob.review; |
|||
|
|||
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.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.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
import com.mydreamjob.review.dto.AdminReviewReplyPageResponse; |
|||
import com.mydreamjob.review.dto.AdminReviewReplyResponse; |
|||
import com.mydreamjob.review.dto.ReviewReplyDecisionRequest; |
|||
import lombok.RequiredArgsConstructor; |
|||
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.io.IOException; |
|||
import java.time.LocalDateTime; |
|||
import java.util.HashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.function.Function; |
|||
import java.util.stream.Collectors; |
|||
|
|||
/** 管理员官方回复审核服务;条件更新保证并发决策只产生一个胜者。 */ |
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class AdminReviewReplyService { |
|||
|
|||
private static final int MAX_PAGE_SIZE = 50; |
|||
private static final int MAX_REQUEST_ID_LENGTH = 32; |
|||
|
|||
private final UserRepository userRepository; |
|||
private final CompanyReviewReplyRepository replyRepository; |
|||
private final CompanyReviewReplyHistoryRepository historyRepository; |
|||
private final JobReviewRepository reviewRepository; |
|||
private final JobRepository jobRepository; |
|||
private final CompanyRepository companyRepository; |
|||
private final AuditService auditService; |
|||
private final ObjectMapper objectMapper; |
|||
|
|||
@Transactional(readOnly = true) |
|||
public AdminReviewReplyPageResponse listPending(Long adminUserId, Long companyId, Long jobId, |
|||
String riskLevel, LocalDateTime createdFrom, |
|||
LocalDateTime createdTo, int page, int size) { |
|||
assertAdmin(adminUserId); |
|||
validatePage(page, size); |
|||
if (createdFrom != null && createdTo != null && createdFrom.isAfter(createdTo)) |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
Page<CompanyReviewReply> replies = replyRepository.findPending( |
|||
companyId, jobId, riskLevel, createdFrom, createdTo, PageRequest.of(page, size)); |
|||
List<Long> reviewIds = replies.getContent().stream().map(CompanyReviewReply::getReviewId).toList(); |
|||
Map<Long, JobReview> reviews = reviewIds.isEmpty() ? Map.of() : reviewRepository.findAllById(reviewIds).stream() |
|||
.collect(Collectors.toMap(JobReview::getId, Function.identity(), (a, b) -> a)); |
|||
Set<Long> jobIds = reviews.values().stream().map(JobReview::getJobId) |
|||
.filter(java.util.Objects::nonNull).collect(Collectors.toSet()); |
|||
Map<Long, Job> jobs = jobIds.isEmpty() ? Map.of() : jobRepository.findAllById(jobIds).stream() |
|||
.collect(Collectors.toMap(Job::getId, Function.identity(), (a, b) -> a)); |
|||
Set<Long> companyIds = jobs.values().stream().map(Job::getCompanyId) |
|||
.filter(java.util.Objects::nonNull).collect(Collectors.toSet()); |
|||
Map<Long, Company> companies = companyIds.isEmpty() ? Map.of() : companyRepository.findAllById(companyIds).stream() |
|||
.collect(Collectors.toMap(Company::getId, Function.identity(), (a, b) -> a)); |
|||
List<AdminReviewReplyResponse> items = replies.getContent().stream() |
|||
.map(reply -> toAdminResponse(reply, reviews, jobs, companies)).toList(); |
|||
return AdminReviewReplyPageResponse.builder() |
|||
.items(items).total(replies.getTotalElements()).page(replies.getNumber()) |
|||
.size(replies.getSize()).totalPages(replies.getTotalPages()).build(); |
|||
} |
|||
|
|||
@Transactional |
|||
public AdminReviewReplyResponse decide(Long adminUserId, Long replyId, |
|||
ReviewReplyDecisionRequest request, |
|||
AuditContext context) { |
|||
assertAdmin(adminUserId); |
|||
if (request == null || request.getDecision() == null) |
|||
throw new BusinessException(ErrorCode.REVIEW_REPLY_DECISION_INVALID); |
|||
String decision = request.getDecision().trim().toUpperCase(); |
|||
if (!"APPROVE".equals(decision) && !"REJECT".equals(decision)) |
|||
throw new BusinessException(ErrorCode.REVIEW_REPLY_DECISION_INVALID); |
|||
String reason = request.getReason() == null ? null : request.getReason().trim(); |
|||
if ("REJECT".equals(decision) && (reason == null || reason.isEmpty())) |
|||
throw new BusinessException(ErrorCode.REVIEW_REPLY_DECISION_INVALID); |
|||
|
|||
CompanyReviewReply current = replyRepository.findById(replyId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_REPLY_NOT_FOUND)); |
|||
JobReview review = reviewRepository.findById(current.getReviewId()).orElse(null); |
|||
if (!CompanyReviewReply.PENDING.equals(current.getModerationStatus())) |
|||
throw new BusinessException(ErrorCode.REVIEW_REPLY_INVALID_STATE); |
|||
|
|||
String finalStatus = "APPROVE".equals(decision) |
|||
? CompanyReviewReply.APPROVED : CompanyReviewReply.REJECTED; |
|||
int updated = replyRepository.decidePending(replyId, current.getVersion(), finalStatus, |
|||
adminUserId, LocalDateTime.now()); |
|||
if (updated != 1) { |
|||
CompanyReviewReply latest = replyRepository.findById(replyId).orElse(null); |
|||
if (latest == null) throw new BusinessException(ErrorCode.REVIEW_REPLY_NOT_FOUND); |
|||
if (!CompanyReviewReply.PENDING.equals(latest.getModerationStatus())) |
|||
throw new BusinessException(ErrorCode.REVIEW_REPLY_INVALID_STATE); |
|||
throw new BusinessException(ErrorCode.REVIEW_REPLY_CONCURRENT_UPDATE); |
|||
} |
|||
|
|||
CompanyReviewReply saved = replyRepository.findById(replyId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_REPLY_NOT_FOUND)); |
|||
String historyAction = "APPROVE".equals(decision) |
|||
? CompanyReviewReplyHistory.APPROVED : CompanyReviewReplyHistory.REJECTED; |
|||
historyRepository.save(CompanyReviewReplyHistory.builder() |
|||
.replyId(replyId).action(historyAction).actorUserId(adminUserId) |
|||
.reasonCodes(saved.getModerationReason()).humanReason(reason).build()); |
|||
auditService.log(adminUserId, |
|||
"APPROVE".equals(decision) ? "REVIEW_REPLY_APPROVED" : "REVIEW_REPLY_REJECTED", |
|||
"COMPANY_REVIEW_REPLY", replyId, |
|||
auditDetails(saved, review, context), requestOnlyContext(context)); |
|||
return toAdminResponse(saved); |
|||
} |
|||
|
|||
private void assertAdmin(Long userId) { |
|||
User user = userRepository.findById(userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
|||
if (user.getRoles() == null || 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 > MAX_PAGE_SIZE) |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
|
|||
private AdminReviewReplyResponse toAdminResponse(CompanyReviewReply reply) { |
|||
JobReview review = reviewRepository.findById(reply.getReviewId()).orElse(null); |
|||
Job job = review == null ? null : jobRepository.findById(review.getJobId()).orElse(null); |
|||
Company company = job == null ? null : companyRepository.findById(job.getCompanyId()).orElse(null); |
|||
return toAdminResponse(reply, |
|||
review == null ? Map.of() : Map.of(review.getId(), review), |
|||
job == null ? Map.of() : Map.of(job.getId(), job), |
|||
company == null ? Map.of() : Map.of(company.getId(), company)); |
|||
} |
|||
|
|||
private AdminReviewReplyResponse toAdminResponse(CompanyReviewReply reply, |
|||
Map<Long, JobReview> reviews, |
|||
Map<Long, Job> jobs, |
|||
Map<Long, Company> companies) { |
|||
JobReview review = reviews.get(reply.getReviewId()); |
|||
Job job = review == null ? null : jobs.get(review.getJobId()); |
|||
Company company = job == null ? null : companies.get(job.getCompanyId()); |
|||
return AdminReviewReplyResponse.builder() |
|||
.replyId(reply.getId()) |
|||
.reviewId(reply.getReviewId()) |
|||
.jobId(job == null ? null : job.getId()) |
|||
.jobTitle(job == null ? null : job.getTitle()) |
|||
.companyId(company == null ? reply.getCompanyId() : company.getId()) |
|||
.companyName(company == null ? null : company.getName()) |
|||
.rating(review == null ? null : review.getRating()) |
|||
.reviewContent(review == null ? null : review.getContent()) |
|||
.reviewModerationStatus(review == null ? null : review.getModerationStatus()) |
|||
.replyContent(reply.getContent()) |
|||
.replyStatus(reply.getModerationStatus()) |
|||
.version(reply.getVersion()) |
|||
.riskScore(reply.getModerationScore()) |
|||
.riskLevel(reply.getRiskLevel()) |
|||
.reasonCodes(parseReasonCodes(reply.getModerationReason())) |
|||
.createdAt(reply.getCreatedAt()) |
|||
.build(); |
|||
} |
|||
|
|||
private List<String> parseReasonCodes(String json) { |
|||
if (json == null || json.isBlank()) return List.of(); |
|||
try { |
|||
return objectMapper.readValue(json, new TypeReference<List<String>>() {}); |
|||
} catch (IOException e) { |
|||
return List.of(); |
|||
} |
|||
} |
|||
|
|||
private String auditDetails(CompanyReviewReply reply, JobReview review, AuditContext context) { |
|||
Map<String, Object> details = new HashMap<>(); |
|||
details.put("reviewId", reply.getReviewId()); |
|||
details.put("replyId", reply.getId()); |
|||
details.put("jobId", review == null ? null : review.getJobId()); |
|||
details.put("status", reply.getModerationStatus()); |
|||
details.put("requestId", context == null ? null : context.getRequestId()); |
|||
try { |
|||
return objectMapper.writeValueAsString(details); |
|||
} catch (Exception e) { |
|||
return "{\"reviewId\":" + reply.getReviewId() + ",\"replyId\":" + reply.getId() |
|||
+ ",\"status\":\"" + reply.getModerationStatus() + "\"}"; |
|||
} |
|||
} |
|||
|
|||
private AuditContext requestOnlyContext(AuditContext context) { |
|||
if (context == null) return null; |
|||
String requestId = context.getRequestId(); |
|||
if (requestId != null && requestId.length() > MAX_REQUEST_ID_LENGTH) |
|||
requestId = requestId.substring(0, MAX_REQUEST_ID_LENGTH); |
|||
return AuditContext.builder().requestId(requestId).build(); |
|||
} |
|||
} |
|||
@ -0,0 +1,102 @@ |
|||
package com.mydreamjob.review; |
|||
|
|||
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.review.dto.CompanyReviewDetailResponse; |
|||
import com.mydreamjob.review.dto.CompanyReviewReplyResponse; |
|||
import com.mydreamjob.review.dto.CreateCompanyReviewReplyRequest; |
|||
import com.mydreamjob.review.dto.ReviewReplyPageResponse; |
|||
import com.mydreamjob.review.dto.UpdateCompanyReviewReplyRequest; |
|||
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.format.annotation.DateTimeFormat; |
|||
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.PutMapping; |
|||
import org.springframework.web.bind.annotation.RequestBody; |
|||
import org.springframework.web.bind.annotation.RequestParam; |
|||
import org.springframework.web.bind.annotation.RestController; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
@Tag(name = "企业评价管理", description = "企业查看自有岗位评价并提交官方回复") |
|||
@RestController |
|||
@RequiredArgsConstructor |
|||
public class CompanyReviewManagementController { |
|||
|
|||
private final CompanyReviewManagementService managementService; |
|||
private final CompanyReviewReplyService replyService; |
|||
|
|||
@Operation(summary = "企业评价列表", description = "仅岗位所属 COMPANY;分页查看本企业岗位评价与回复状态") |
|||
@GetMapping("/companies/me/job-reviews") |
|||
public ResponseEntity<ApiResponse<ReviewReplyPageResponse>> list( |
|||
@RequestParam(required = false) Long jobId, |
|||
@RequestParam(required = false) Integer rating, |
|||
@RequestParam(required = false) String reviewModerationStatus, |
|||
@RequestParam(required = false) String replyStatus, |
|||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) |
|||
LocalDateTime createdFrom, |
|||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) |
|||
LocalDateTime createdTo, |
|||
@RequestParam(defaultValue = "0") int page, |
|||
@RequestParam(defaultValue = "20") int size, |
|||
HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
return ResponseEntity.ok(ApiResponse.ok(managementService.listMine(userId, jobId, rating, |
|||
reviewModerationStatus, replyStatus, createdFrom, createdTo, page, size), requestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "企业评价详情", description = "仅岗位所属 COMPANY;不返回评价作者身份") |
|||
@GetMapping("/companies/me/job-reviews/{reviewId}") |
|||
public ResponseEntity<ApiResponse<CompanyReviewDetailResponse>> detail( |
|||
@PathVariable Long reviewId, HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
return ResponseEntity.ok(ApiResponse.ok(managementService.getMine(userId, reviewId), requestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "创建官方回复", description = "仅岗位所属 COMPANY;安全文本自动公开,风险文本进入管理员审核") |
|||
@PostMapping("/companies/me/job-reviews/{reviewId}/reply") |
|||
public ResponseEntity<ApiResponse<CompanyReviewReplyResponse>> createReply( |
|||
@PathVariable Long reviewId, |
|||
@Valid @RequestBody CreateCompanyReviewReplyRequest requestBody, |
|||
HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
return ResponseEntity.ok(ApiResponse.ok(replyService.create(userId, reviewId, requestBody, |
|||
AuditContext.from(request)), requestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "修订被驳回回复", description = "仅 REJECTED 回复可修订;使用 expectedVersion 防并发覆盖") |
|||
@PutMapping("/companies/me/job-reviews/{reviewId}/reply") |
|||
public ResponseEntity<ApiResponse<CompanyReviewReplyResponse>> reviseReply( |
|||
@PathVariable Long reviewId, |
|||
@Valid @RequestBody UpdateCompanyReviewReplyRequest requestBody, |
|||
HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
return ResponseEntity.ok(ApiResponse.ok(replyService.revise(userId, reviewId, requestBody, |
|||
AuditContext.from(request)), requestId(request))); |
|||
} |
|||
|
|||
private Long resolveUserId() { |
|||
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); |
|||
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getName())) |
|||
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
|||
try { |
|||
return Long.valueOf(auth.getName()); |
|||
} catch (NumberFormatException ex) { |
|||
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
|||
} |
|||
} |
|||
|
|||
private String requestId(HttpServletRequest request) { |
|||
return request.getHeader("X-Request-Id"); |
|||
} |
|||
} |
|||
@ -0,0 +1,196 @@ |
|||
package com.mydreamjob.review; |
|||
|
|||
import com.fasterxml.jackson.core.type.TypeReference; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyService; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
import com.mydreamjob.review.dto.CompanyReviewDetailResponse; |
|||
import com.mydreamjob.review.dto.CompanyReviewItemResponse; |
|||
import com.mydreamjob.review.dto.CompanyReviewJobResponse; |
|||
import com.mydreamjob.review.dto.CompanyReviewReplyResponse; |
|||
import com.mydreamjob.review.dto.ReviewReplyPageResponse; |
|||
import lombok.RequiredArgsConstructor; |
|||
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.io.IOException; |
|||
import java.time.LocalDateTime; |
|||
import java.util.HashMap; |
|||
import java.util.HashSet; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.function.Function; |
|||
import java.util.stream.Collectors; |
|||
|
|||
/** 企业评价管理只读投影;所有查询均限定在当前企业拥有的岗位。 */ |
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class CompanyReviewManagementService { |
|||
|
|||
private static final int MAX_PAGE_SIZE = 50; |
|||
private static final Set<String> REVIEW_STATUSES = Set.of( |
|||
JobReview.MODERATION_AUTO_APPROVED, JobReview.MODERATION_PENDING, |
|||
JobReview.MODERATION_APPROVED, JobReview.MODERATION_REJECTED); |
|||
private static final Set<String> REPLY_STATUSES = Set.of( |
|||
"NONE", CompanyReviewReply.AUTO_APPROVED, CompanyReviewReply.PENDING, |
|||
CompanyReviewReply.APPROVED, CompanyReviewReply.REJECTED); |
|||
|
|||
private final CompanyService companyService; |
|||
private final JobReviewRepository reviewRepository; |
|||
private final JobRepository jobRepository; |
|||
private final JobReviewDimensionScoreRepository dimensionScoreRepository; |
|||
private final CompanyReviewReplyRepository replyRepository; |
|||
private final CompanyReviewReplyHistoryRepository historyRepository; |
|||
private final ObjectMapper objectMapper; |
|||
|
|||
@Transactional(readOnly = true) |
|||
public ReviewReplyPageResponse listMine(Long userId, Long jobId, Integer rating, |
|||
String reviewModerationStatus, String replyStatus, |
|||
LocalDateTime createdFrom, LocalDateTime createdTo, |
|||
int page, int size) { |
|||
Company company = requireCompany(userId); |
|||
validatePage(page, size); |
|||
validateFilters(rating, reviewModerationStatus, replyStatus, createdFrom, createdTo); |
|||
|
|||
Page<JobReview> reviewPage = reviewRepository.findCompanyReviews( |
|||
company.getId(), jobId, rating, reviewModerationStatus, replyStatus, |
|||
createdFrom, createdTo, PageRequest.of(page, size)); |
|||
List<Long> reviewIds = reviewPage.getContent().stream().map(JobReview::getId).toList(); |
|||
Map<Long, Job> jobs = loadJobs(reviewPage.getContent()); |
|||
Map<Long, Map<String, Integer>> dimensions = loadDimensions(reviewIds); |
|||
Map<Long, CompanyReviewReply> replies = loadReplies(reviewIds); |
|||
Map<Long, String> rejectionReasons = loadRejectionReasons(replies.values()); |
|||
|
|||
List<CompanyReviewItemResponse> items = reviewPage.getContent().stream() |
|||
.map(r -> toItem(r, jobs.get(r.getJobId()), dimensions.getOrDefault(r.getId(), Map.of()), |
|||
replies.get(r.getId()), rejectionReasons.get(r.getId()))) |
|||
.toList(); |
|||
return ReviewReplyPageResponse.builder() |
|||
.items(items).total(reviewPage.getTotalElements()).page(reviewPage.getNumber()) |
|||
.size(reviewPage.getSize()).totalPages(reviewPage.getTotalPages()).build(); |
|||
} |
|||
|
|||
@Transactional(readOnly = true) |
|||
public CompanyReviewDetailResponse getMine(Long userId, Long reviewId) { |
|||
Company company = requireCompany(userId); |
|||
JobReview review = reviewRepository.findById(reviewId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_REPLY_NOT_FOUND)); |
|||
Job job = jobRepository.findById(review.getJobId()) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_REPLY_NOT_FOUND)); |
|||
assertOwnership(company, job); |
|||
CompanyReviewReply reply = replyRepository.findByReviewId(reviewId).orElse(null); |
|||
String rejectionReason = reply == null ? null : loadRejectionReasons(List.of(reply)).get(reply.getId()); |
|||
CompanyReviewItemResponse item = toItem(review, job, |
|||
loadDimensions(List.of(reviewId)).getOrDefault(reviewId, Map.of()), reply, rejectionReason); |
|||
CompanyReviewDetailResponse detail = new CompanyReviewDetailResponse(); |
|||
detail.setReviewId(item.getReviewId()); |
|||
detail.setJob(item.getJob()); |
|||
detail.setRating(item.getRating()); |
|||
detail.setContent(item.getContent()); |
|||
detail.setTags(item.getTags()); |
|||
detail.setDimensionScores(item.getDimensionScores()); |
|||
detail.setReviewStatus(item.getReviewStatus()); |
|||
detail.setReviewCreatedAt(item.getReviewCreatedAt()); |
|||
detail.setReply(item.getReply()); |
|||
return detail; |
|||
} |
|||
|
|||
private Company requireCompany(Long userId) { |
|||
Company company = companyService.findCompanyByOwnerUserId(userId); |
|||
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); |
|||
return company; |
|||
} |
|||
|
|||
private void assertOwnership(Company company, Job job) { |
|||
if (job == null || !company.getId().equals(job.getCompanyId())) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
} |
|||
|
|||
private void validatePage(int page, int size) { |
|||
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
|
|||
private void validateFilters(Integer rating, String reviewStatus, String replyStatus, |
|||
LocalDateTime from, LocalDateTime to) { |
|||
if (rating != null && (rating < 1 || rating > 5)) |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
if (reviewStatus != null && !REVIEW_STATUSES.contains(reviewStatus)) |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
if (replyStatus != null && !REPLY_STATUSES.contains(replyStatus)) |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
if (from != null && to != null && from.isAfter(to)) |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
|
|||
private Map<Long, Job> loadJobs(List<JobReview> reviews) { |
|||
Set<Long> ids = reviews.stream().map(JobReview::getJobId).collect(Collectors.toSet()); |
|||
if (ids.isEmpty()) return Map.of(); |
|||
return jobRepository.findAllById(ids).stream() |
|||
.collect(Collectors.toMap(Job::getId, Function.identity())); |
|||
} |
|||
|
|||
private Map<Long, Map<String, Integer>> loadDimensions(List<Long> reviewIds) { |
|||
if (reviewIds.isEmpty()) return Map.of(); |
|||
Map<Long, Map<String, Integer>> result = new HashMap<>(); |
|||
dimensionScoreRepository.findByReviewIdIn(reviewIds).forEach(d -> |
|||
result.computeIfAbsent(d.getReviewId(), ignored -> new HashMap<>()) |
|||
.put(d.getDimensionCode(), d.getScore())); |
|||
return result; |
|||
} |
|||
|
|||
private Map<Long, CompanyReviewReply> loadReplies(List<Long> reviewIds) { |
|||
if (reviewIds.isEmpty()) return Map.of(); |
|||
return replyRepository.findByReviewIdIn(reviewIds).stream() |
|||
.collect(Collectors.toMap(CompanyReviewReply::getReviewId, Function.identity())); |
|||
} |
|||
|
|||
private Map<Long, String> loadRejectionReasons(java.util.Collection<CompanyReviewReply> replies) { |
|||
List<Long> ids = replies.stream().map(CompanyReviewReply::getId).filter(java.util.Objects::nonNull).toList(); |
|||
if (ids.isEmpty()) return Map.of(); |
|||
Map<Long, String> result = new HashMap<>(); |
|||
historyRepository.findByReplyIdIn(ids).stream() |
|||
.filter(h -> CompanyReviewReplyHistory.REJECTED.equals(h.getAction())) |
|||
.sorted((a, b) -> b.getCreatedAt().compareTo(a.getCreatedAt())) |
|||
.forEach(h -> result.putIfAbsent(h.getReplyId(), h.getHumanReason())); |
|||
return result; |
|||
} |
|||
|
|||
private CompanyReviewItemResponse toItem(JobReview review, Job job, |
|||
Map<String, Integer> dimensions, |
|||
CompanyReviewReply reply, String rejectionReason) { |
|||
return CompanyReviewItemResponse.builder() |
|||
.reviewId(review.getId()) |
|||
.job(job == null ? null : new CompanyReviewJobResponse(job.getId(), job.getTitle())) |
|||
.rating(review.getRating()) |
|||
.content(review.getContent()) |
|||
.tags(parseTags(review.getTags())) |
|||
.dimensionScores(dimensions) |
|||
.reviewStatus(review.getModerationStatus()) |
|||
.reviewCreatedAt(review.getCreatedAt()) |
|||
.reply(reply == null ? null : CompanyReviewReplyResponse.builder() |
|||
.id(reply.getId()).content(reply.getContent()).status(reply.getModerationStatus()) |
|||
.version(reply.getVersion()).updatedAt(reply.getUpdatedAt()) |
|||
.rejectionReason(CompanyReviewReply.REJECTED.equals(reply.getModerationStatus()) |
|||
? rejectionReason : null) |
|||
.build()) |
|||
.build(); |
|||
} |
|||
|
|||
private List<String> parseTags(String json) { |
|||
if (json == null || json.isBlank()) return List.of(); |
|||
try { |
|||
return objectMapper.readValue(json, new TypeReference<List<String>>() {}); |
|||
} catch (IOException e) { |
|||
return List.of(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,90 @@ |
|||
package com.mydreamjob.review; |
|||
|
|||
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.Table; |
|||
import jakarta.persistence.UniqueConstraint; |
|||
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.math.BigDecimal; |
|||
import java.time.LocalDateTime; |
|||
|
|||
/** 企业对岗位评价的当前官方回复;正文在 AUTO_APPROVED/APPROVED 后不可覆盖。 */ |
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@Entity |
|||
@Table(name = "company_review_replies", |
|||
uniqueConstraints = { |
|||
@UniqueConstraint(name = "uk_company_review_replies_review", columnNames = "review_id"), |
|||
@UniqueConstraint(name = "uk_company_review_replies_idempotency", |
|||
columnNames = {"company_id", "idempotency_key"}) |
|||
}) |
|||
@EntityListeners(AuditingEntityListener.class) |
|||
public class CompanyReviewReply { |
|||
|
|||
public static final String AUTO_APPROVED = "AUTO_APPROVED"; |
|||
public static final String PENDING = "PENDING"; |
|||
public static final String APPROVED = "APPROVED"; |
|||
public static final String REJECTED = "REJECTED"; |
|||
|
|||
@Id |
|||
@GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
|
|||
@Column(name = "review_id", nullable = false) |
|||
private Long reviewId; |
|||
|
|||
@Column(name = "company_id", nullable = false) |
|||
private Long companyId; |
|||
|
|||
@Column(nullable = false, length = 500) |
|||
private String content; |
|||
|
|||
@Column(name = "idempotency_key", nullable = false, length = 64) |
|||
private String idempotencyKey; |
|||
|
|||
@Column(name = "moderation_status", nullable = false, length = 20) |
|||
@Builder.Default |
|||
private String moderationStatus = PENDING; |
|||
|
|||
@Column(name = "moderation_reason", length = 500) |
|||
private String moderationReason; |
|||
|
|||
@Column(name = "moderation_score", precision = 5, scale = 2) |
|||
private BigDecimal moderationScore; |
|||
|
|||
@Column(name = "risk_level", length = 20) |
|||
private String riskLevel; |
|||
|
|||
@Column(name = "reviewed_by_user_id") |
|||
private Long reviewedByUserId; |
|||
|
|||
@Column(name = "reviewed_at") |
|||
private LocalDateTime reviewedAt; |
|||
|
|||
@Version |
|||
@Column(nullable = false) |
|||
@Builder.Default |
|||
private Long version = 0L; |
|||
|
|||
@CreatedDate |
|||
@Column(name = "created_at", nullable = false, updatable = false) |
|||
private LocalDateTime createdAt; |
|||
|
|||
@LastModifiedDate |
|||
@Column(name = "updated_at", nullable = false) |
|||
private LocalDateTime updatedAt; |
|||
} |
|||
@ -0,0 +1,59 @@ |
|||
package com.mydreamjob.review; |
|||
|
|||
import jakarta.persistence.Column; |
|||
import jakarta.persistence.Entity; |
|||
import jakarta.persistence.GeneratedValue; |
|||
import jakarta.persistence.GenerationType; |
|||
import jakarta.persistence.Id; |
|||
import jakarta.persistence.PrePersist; |
|||
import jakarta.persistence.Table; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
/** 官方回复生命周期历史;不保存正文副本。 */ |
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@Entity |
|||
@Table(name = "company_review_reply_history") |
|||
public class CompanyReviewReplyHistory { |
|||
|
|||
public static final String CREATED = "CREATED"; |
|||
public static final String AUTO_APPROVED = "AUTO_APPROVED"; |
|||
public static final String PENDING = "PENDING"; |
|||
public static final String RESUBMITTED = "RESUBMITTED"; |
|||
public static final String APPROVED = "APPROVED"; |
|||
public static final String REJECTED = "REJECTED"; |
|||
|
|||
@Id |
|||
@GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
|
|||
@Column(name = "reply_id", nullable = false) |
|||
private Long replyId; |
|||
|
|||
@Column(nullable = false, length = 30) |
|||
private String action; |
|||
|
|||
@Column(name = "actor_user_id") |
|||
private Long actorUserId; |
|||
|
|||
@Column(name = "reason_codes", length = 500) |
|||
private String reasonCodes; |
|||
|
|||
@Column(name = "human_reason", length = 500) |
|||
private String humanReason; |
|||
|
|||
@Column(name = "created_at", nullable = false, updatable = false) |
|||
private LocalDateTime createdAt; |
|||
|
|||
@PrePersist |
|||
void prePersist() { |
|||
if (createdAt == null) createdAt = LocalDateTime.now(); |
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
package com.mydreamjob.review; |
|||
|
|||
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 CompanyReviewReplyHistoryRepository extends JpaRepository<CompanyReviewReplyHistory, Long> { |
|||
|
|||
List<CompanyReviewReplyHistory> findByReplyIdIn(Collection<Long> replyIds); |
|||
|
|||
List<CompanyReviewReplyHistory> findByReplyIdOrderByCreatedAtDesc(Long replyId); |
|||
|
|||
Optional<CompanyReviewReplyHistory> findTopByReplyIdAndActionOrderByCreatedAtDesc( |
|||
Long replyId, String action); |
|||
} |
|||
@ -0,0 +1,64 @@ |
|||
package com.mydreamjob.review; |
|||
|
|||
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.Modifying; |
|||
import org.springframework.data.jpa.repository.Query; |
|||
import org.springframework.data.repository.query.Param; |
|||
import org.springframework.stereotype.Repository; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.Collection; |
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
|
|||
@Repository |
|||
public interface CompanyReviewReplyRepository extends JpaRepository<CompanyReviewReply, Long> { |
|||
|
|||
Optional<CompanyReviewReply> findByReviewId(Long reviewId); |
|||
|
|||
Optional<CompanyReviewReply> findByCompanyIdAndIdempotencyKey(Long companyId, String idempotencyKey); |
|||
|
|||
List<CompanyReviewReply> findByReviewIdIn(Collection<Long> reviewIds); |
|||
|
|||
@Query(value = "SELECT rr.* FROM company_review_replies rr " |
|||
+ "JOIN job_reviews r ON r.id = rr.review_id " |
|||
+ "JOIN jobs j ON j.id = r.job_id " |
|||
+ "WHERE rr.moderation_status = 'PENDING' " |
|||
+ "AND (:companyId IS NULL OR rr.company_id = :companyId) " |
|||
+ "AND (:jobId IS NULL OR r.job_id = :jobId) " |
|||
+ "AND (:riskLevel IS NULL OR rr.risk_level = :riskLevel) " |
|||
+ "AND (:createdFrom IS NULL OR rr.created_at >= :createdFrom) " |
|||
+ "AND (:createdTo IS NULL OR rr.created_at <= :createdTo) " |
|||
+ "ORDER BY rr.created_at ASC", |
|||
countQuery = "SELECT COUNT(*) FROM company_review_replies rr " |
|||
+ "JOIN job_reviews r ON r.id = rr.review_id " |
|||
+ "JOIN jobs j ON j.id = r.job_id " |
|||
+ "WHERE rr.moderation_status = 'PENDING' " |
|||
+ "AND (:companyId IS NULL OR rr.company_id = :companyId) " |
|||
+ "AND (:jobId IS NULL OR r.job_id = :jobId) " |
|||
+ "AND (:riskLevel IS NULL OR rr.risk_level = :riskLevel) " |
|||
+ "AND (:createdFrom IS NULL OR rr.created_at >= :createdFrom) " |
|||
+ "AND (:createdTo IS NULL OR rr.created_at <= :createdTo)", |
|||
nativeQuery = true) |
|||
Page<CompanyReviewReply> findPending(@Param("companyId") Long companyId, |
|||
@Param("jobId") Long jobId, |
|||
@Param("riskLevel") String riskLevel, |
|||
@Param("createdFrom") LocalDateTime createdFrom, |
|||
@Param("createdTo") LocalDateTime createdTo, |
|||
Pageable pageable); |
|||
|
|||
@Modifying(clearAutomatically = true, flushAutomatically = true) |
|||
@Transactional |
|||
@Query("UPDATE CompanyReviewReply r SET r.moderationStatus = :status, " |
|||
+ "r.reviewedByUserId = :reviewedBy, r.reviewedAt = :reviewedAt, " |
|||
+ "r.version = r.version + 1 " |
|||
+ "WHERE r.id = :id AND r.version = :version AND r.moderationStatus = 'PENDING'") |
|||
int decidePending(@Param("id") Long id, |
|||
@Param("version") Long version, |
|||
@Param("status") String status, |
|||
@Param("reviewedBy") Long reviewedBy, |
|||
@Param("reviewedAt") LocalDateTime reviewedAt); |
|||
} |
|||
@ -0,0 +1,237 @@ |
|||
package com.mydreamjob.review; |
|||
|
|||
import com.fasterxml.jackson.core.JsonProcessingException; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.audit.AuditService; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyService; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
import com.mydreamjob.review.dto.CompanyReviewReplyResponse; |
|||
import com.mydreamjob.review.dto.CreateCompanyReviewReplyRequest; |
|||
import com.mydreamjob.review.dto.UpdateCompanyReviewReplyRequest; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.dao.DataIntegrityViolationException; |
|||
import org.springframework.orm.ObjectOptimisticLockingFailureException; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.UUID; |
|||
|
|||
/** 企业官方回复写服务:归属、状态机、幂等、预审和白名单审计。 */ |
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class CompanyReviewReplyService { |
|||
|
|||
private static final int MAX_REQUEST_ID_LENGTH = 32; |
|||
|
|||
private final CompanyService companyService; |
|||
private final JobReviewRepository reviewRepository; |
|||
private final JobRepository jobRepository; |
|||
private final CompanyReviewReplyRepository replyRepository; |
|||
private final CompanyReviewReplyWriter replyWriter; |
|||
private final CompanyReviewReplyHistoryRepository historyRepository; |
|||
private final ReviewModerationService moderationService; |
|||
private final AuditService auditService; |
|||
private final ObjectMapper objectMapper; |
|||
|
|||
@Transactional |
|||
public CompanyReviewReplyResponse create(Long userId, Long reviewId, |
|||
CreateCompanyReviewReplyRequest request, |
|||
AuditContext context) { |
|||
Company company = requireCompany(userId); |
|||
String content = normalizeContent(request == null ? null : request.getContent()); |
|||
String idempotencyKey = normalizeIdempotencyKey(request == null ? null : request.getIdempotencyKey()); |
|||
JobReview review = findOwnedReview(company, reviewId); |
|||
|
|||
CompanyReviewReply byKey = replyRepository.findByCompanyIdAndIdempotencyKey(company.getId(), idempotencyKey) |
|||
.orElse(null); |
|||
if (byKey != null) { |
|||
if (byKey.getReviewId().equals(reviewId) && content.equals(byKey.getContent())) { |
|||
return toResponse(byKey, null); |
|||
} |
|||
throw new BusinessException(ErrorCode.REVIEW_REPLY_IDEMPOTENCY_CONFLICT); |
|||
} |
|||
|
|||
CompanyReviewReply existing = replyRepository.findByReviewId(reviewId).orElse(null); |
|||
if (existing != null) { |
|||
if (CompanyReviewReply.REJECTED.equals(existing.getModerationStatus())) { |
|||
throw new BusinessException(ErrorCode.REVIEW_REPLY_INVALID_STATE); |
|||
} |
|||
throw new BusinessException(ErrorCode.REVIEW_REPLY_ALREADY_EXISTS); |
|||
} |
|||
|
|||
ReviewModerationService.ModerationResult moderation = moderationService.moderateCompanyReply(content); |
|||
CompanyReviewReply reply = CompanyReviewReply.builder() |
|||
.reviewId(reviewId) |
|||
.companyId(company.getId()) |
|||
.content(content) |
|||
.idempotencyKey(idempotencyKey) |
|||
.moderationStatus(moderation.getStatus()) |
|||
.moderationReason(toJson(moderation.getReasonCodes())) |
|||
.moderationScore(moderation.getScore()) |
|||
.riskLevel(moderation.getRiskLevel()) |
|||
.build(); |
|||
try { |
|||
// 让唯一键冲突只回滚独立写事务,避免并发失败污染当前事务。
|
|||
reply = replyWriter == null |
|||
? replyRepository.saveAndFlush(reply) // 兼容纯单元测试直接注入 service 的场景
|
|||
: replyWriter.saveAndFlush(reply); |
|||
} catch (DataIntegrityViolationException ex) { |
|||
// 唯一约束是并发最终防线;优先返回可确定的业务冲突,不把它泄露为 500。
|
|||
CompanyReviewReply concurrent = replyRepository.findByCompanyIdAndIdempotencyKey( |
|||
company.getId(), idempotencyKey).orElse(null); |
|||
if (concurrent != null && concurrent.getReviewId().equals(reviewId) |
|||
&& content.equals(concurrent.getContent())) { |
|||
return toResponse(concurrent, null); |
|||
} |
|||
if (concurrent != null) throw new BusinessException(ErrorCode.REVIEW_REPLY_IDEMPOTENCY_CONFLICT); |
|||
if (replyRepository.findByReviewId(reviewId).isPresent()) { |
|||
throw new BusinessException(ErrorCode.REVIEW_REPLY_ALREADY_EXISTS); |
|||
} |
|||
throw new BusinessException(ErrorCode.REVIEW_REPLY_ALREADY_EXISTS); |
|||
} |
|||
|
|||
saveHistory(reply.getId(), CompanyReviewReplyHistory.CREATED, userId, |
|||
moderation.getReasonCodes(), null); |
|||
String statusAction = CompanyReviewReply.AUTO_APPROVED.equals(reply.getModerationStatus()) |
|||
? CompanyReviewReplyHistory.AUTO_APPROVED : CompanyReviewReplyHistory.PENDING; |
|||
saveHistory(reply.getId(), statusAction, null, moderation.getReasonCodes(), null); |
|||
audit(userId, "COMPANY_REVIEW_REPLY_CREATED", reply, review, context); |
|||
if (CompanyReviewReply.AUTO_APPROVED.equals(reply.getModerationStatus())) { |
|||
audit(userId, "REVIEW_REPLY_AUTO_APPROVED", reply, review, context); |
|||
} |
|||
return toResponse(reply, null); |
|||
} |
|||
|
|||
@Transactional |
|||
public CompanyReviewReplyResponse revise(Long userId, Long reviewId, |
|||
UpdateCompanyReviewReplyRequest request, |
|||
AuditContext context) { |
|||
Company company = requireCompany(userId); |
|||
JobReview review = findOwnedReview(company, reviewId); |
|||
String content = normalizeContent(request == null ? null : request.getContent()); |
|||
Long expectedVersion = request == null ? null : request.getExpectedVersion(); |
|||
if (expectedVersion == null) throw new BusinessException(ErrorCode.REVIEW_REPLY_CONCURRENT_UPDATE); |
|||
|
|||
CompanyReviewReply reply = replyRepository.findByReviewId(reviewId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_REPLY_NOT_FOUND)); |
|||
if (!CompanyReviewReply.REJECTED.equals(reply.getModerationStatus())) { |
|||
throw new BusinessException(ErrorCode.REVIEW_REPLY_INVALID_STATE); |
|||
} |
|||
if (!expectedVersion.equals(reply.getVersion())) { |
|||
throw new BusinessException(ErrorCode.REVIEW_REPLY_CONCURRENT_UPDATE); |
|||
} |
|||
|
|||
ReviewModerationService.ModerationResult moderation = moderationService.moderateCompanyReply(content); |
|||
reply.setContent(content); |
|||
reply.setModerationStatus(moderation.getStatus()); |
|||
reply.setModerationReason(toJson(moderation.getReasonCodes())); |
|||
reply.setModerationScore(moderation.getScore()); |
|||
reply.setRiskLevel(moderation.getRiskLevel()); |
|||
reply.setReviewedByUserId(null); |
|||
reply.setReviewedAt(null); |
|||
try { |
|||
reply = replyRepository.saveAndFlush(reply); |
|||
} catch (ObjectOptimisticLockingFailureException ex) { |
|||
throw new BusinessException(ErrorCode.REVIEW_REPLY_CONCURRENT_UPDATE); |
|||
} |
|||
|
|||
saveHistory(reply.getId(), CompanyReviewReplyHistory.RESUBMITTED, userId, |
|||
moderation.getReasonCodes(), null); |
|||
String statusAction = CompanyReviewReply.AUTO_APPROVED.equals(reply.getModerationStatus()) |
|||
? CompanyReviewReplyHistory.AUTO_APPROVED : CompanyReviewReplyHistory.PENDING; |
|||
saveHistory(reply.getId(), statusAction, null, moderation.getReasonCodes(), null); |
|||
audit(userId, "COMPANY_REVIEW_REPLY_RESUBMITTED", reply, review, context); |
|||
if (CompanyReviewReply.AUTO_APPROVED.equals(reply.getModerationStatus())) { |
|||
audit(userId, "REVIEW_REPLY_AUTO_APPROVED", reply, review, context); |
|||
} |
|||
return toResponse(reply, null); |
|||
} |
|||
|
|||
private Company requireCompany(Long userId) { |
|||
Company company = companyService.findCompanyByOwnerUserId(userId); |
|||
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); |
|||
return company; |
|||
} |
|||
|
|||
private JobReview findOwnedReview(Company company, Long reviewId) { |
|||
if (reviewId == null) throw new BusinessException(ErrorCode.REVIEW_REPLY_NOT_FOUND); |
|||
JobReview review = reviewRepository.findById(reviewId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_REPLY_NOT_FOUND)); |
|||
Job job = jobRepository.findById(review.getJobId()) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_REPLY_NOT_FOUND)); |
|||
if (!company.getId().equals(job.getCompanyId())) throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
return review; |
|||
} |
|||
|
|||
private String normalizeContent(String value) { |
|||
if (value == null) throw new BusinessException(ErrorCode.REVIEW_REPLY_CONTENT_INVALID); |
|||
String normalized = value.trim(); |
|||
if (normalized.isEmpty() || normalized.length() > 500) |
|||
throw new BusinessException(ErrorCode.REVIEW_REPLY_CONTENT_INVALID); |
|||
return normalized; |
|||
} |
|||
|
|||
private String normalizeIdempotencyKey(String value) { |
|||
if (value == null || value.isBlank() || value.length() > 64) |
|||
throw new BusinessException(ErrorCode.REVIEW_REPLY_CONTENT_INVALID); |
|||
String normalized = value.trim(); |
|||
try { |
|||
UUID.fromString(normalized); |
|||
} catch (IllegalArgumentException ex) { |
|||
throw new BusinessException(ErrorCode.REVIEW_REPLY_CONTENT_INVALID); |
|||
} |
|||
return normalized; |
|||
} |
|||
|
|||
private void saveHistory(Long replyId, String action, Long actorUserId, |
|||
List<String> reasonCodes, String humanReason) { |
|||
historyRepository.save(CompanyReviewReplyHistory.builder() |
|||
.replyId(replyId).action(action).actorUserId(actorUserId) |
|||
.reasonCodes(toJson(reasonCodes)).humanReason(humanReason).build()); |
|||
} |
|||
|
|||
private CompanyReviewReplyResponse toResponse(CompanyReviewReply reply, String rejectionReason) { |
|||
return CompanyReviewReplyResponse.builder() |
|||
.id(reply.getId()).content(reply.getContent()).status(reply.getModerationStatus()) |
|||
.version(reply.getVersion()).updatedAt(reply.getUpdatedAt()) |
|||
.rejectionReason(rejectionReason).build(); |
|||
} |
|||
|
|||
private String toJson(Object value) { |
|||
if (value == null) return null; |
|||
try { |
|||
return objectMapper.writeValueAsString(value); |
|||
} catch (JsonProcessingException e) { |
|||
throw new IllegalStateException("failed to serialize review reply metadata", e); |
|||
} |
|||
} |
|||
|
|||
private void audit(Long userId, String action, CompanyReviewReply reply, |
|||
JobReview review, AuditContext context) { |
|||
Map<String, Object> details = new LinkedHashMap<>(); |
|||
details.put("reviewId", reply.getReviewId()); |
|||
details.put("replyId", reply.getId()); |
|||
details.put("jobId", review == null ? null : review.getJobId()); |
|||
details.put("status", reply.getModerationStatus()); |
|||
details.put("requestId", context == null ? null : context.getRequestId()); |
|||
auditService.log(userId, action, "COMPANY_REVIEW_REPLY", reply.getId(), toJson(details), |
|||
requestOnlyContext(context)); |
|||
} |
|||
|
|||
private AuditContext requestOnlyContext(AuditContext context) { |
|||
if (context == null) return null; |
|||
String requestId = context.getRequestId(); |
|||
if (requestId != null && requestId.length() > MAX_REQUEST_ID_LENGTH) |
|||
requestId = requestId.substring(0, MAX_REQUEST_ID_LENGTH); |
|||
return AuditContext.builder().requestId(requestId).build(); |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
package com.mydreamjob.review; |
|||
|
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Propagation; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
/** |
|||
* 回复创建的独立落库事务。 |
|||
* |
|||
* 唯一键冲突是并发创建的正常业务分支;将 flush 隔离到新事务,避免 |
|||
* 数据库约束异常把外层查询、幂等判定和审计事务标记为 rollback-only。 |
|||
*/ |
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class CompanyReviewReplyWriter { |
|||
|
|||
private final CompanyReviewReplyRepository replyRepository; |
|||
|
|||
@Transactional(propagation = Propagation.REQUIRES_NEW) |
|||
public CompanyReviewReply saveAndFlush(CompanyReviewReply reply) { |
|||
return replyRepository.saveAndFlush(reply); |
|||
} |
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
package com.mydreamjob.review.dto; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
public class AdminReviewReplyPageResponse { |
|||
private List<AdminReviewReplyResponse> items; |
|||
private long total; |
|||
private int page; |
|||
private int size; |
|||
private int totalPages; |
|||
} |
|||
@ -0,0 +1,33 @@ |
|||
package com.mydreamjob.review.dto; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
public class AdminReviewReplyResponse { |
|||
private Long replyId; |
|||
private Long reviewId; |
|||
private Long jobId; |
|||
private String jobTitle; |
|||
private Long companyId; |
|||
private String companyName; |
|||
private Integer rating; |
|||
private String reviewContent; |
|||
private String reviewModerationStatus; |
|||
private String replyContent; |
|||
private String replyStatus; |
|||
private Long version; |
|||
private BigDecimal riskScore; |
|||
private String riskLevel; |
|||
private List<String> reasonCodes; |
|||
private LocalDateTime createdAt; |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
package com.mydreamjob.review.dto; |
|||
|
|||
import lombok.Data; |
|||
|
|||
/** 详情与列表使用同一白名单字段,避免 Entity 直接序列化。 */ |
|||
@Data |
|||
public class CompanyReviewDetailResponse extends CompanyReviewItemResponse { |
|||
} |
|||
@ -0,0 +1,26 @@ |
|||
package com.mydreamjob.review.dto; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
public class CompanyReviewItemResponse { |
|||
private Long reviewId; |
|||
private CompanyReviewJobResponse job; |
|||
private Integer rating; |
|||
private String content; |
|||
private List<String> tags; |
|||
private Map<String, Integer> dimensionScores; |
|||
private String reviewStatus; |
|||
private LocalDateTime reviewCreatedAt; |
|||
private CompanyReviewReplyResponse reply; |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
package com.mydreamjob.review.dto; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
@Data |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
public class CompanyReviewJobResponse { |
|||
private Long id; |
|||
private String title; |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
package com.mydreamjob.review.dto; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
public class CompanyReviewReplyResponse { |
|||
private Long id; |
|||
private String content; |
|||
private String status; |
|||
private Long version; |
|||
private LocalDateTime updatedAt; |
|||
private String rejectionReason; |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
package com.mydreamjob.review.dto; |
|||
|
|||
import jakarta.validation.constraints.NotBlank; |
|||
import jakarta.validation.constraints.Size; |
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
public class CreateCompanyReviewReplyRequest { |
|||
@NotBlank(message = "content is required") |
|||
@Size(max = 500, message = "content must not exceed 500 characters") |
|||
private String content; |
|||
|
|||
@NotBlank(message = "idempotencyKey is required") |
|||
@Size(max = 64, message = "idempotencyKey must not exceed 64 characters") |
|||
private String idempotencyKey; |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
package com.mydreamjob.review.dto; |
|||
|
|||
import jakarta.validation.constraints.NotBlank; |
|||
import jakarta.validation.constraints.Size; |
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
public class ReviewReplyDecisionRequest { |
|||
@NotBlank(message = "decision is required") |
|||
private String decision; |
|||
|
|||
@Size(max = 500, message = "reason must not exceed 500 characters") |
|||
private String reason; |
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
package com.mydreamjob.review.dto; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
public class ReviewReplyPageResponse { |
|||
private List<CompanyReviewItemResponse> items; |
|||
private long total; |
|||
private int page; |
|||
private int size; |
|||
private int totalPages; |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
package com.mydreamjob.review.dto; |
|||
|
|||
import jakarta.validation.constraints.NotNull; |
|||
import jakarta.validation.constraints.PositiveOrZero; |
|||
import jakarta.validation.constraints.Size; |
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
public class UpdateCompanyReviewReplyRequest { |
|||
@Size(min = 1, max = 500, message = "content must be 1-500 characters") |
|||
private String content; |
|||
|
|||
@NotNull(message = "expectedVersion is required") |
|||
@PositiveOrZero(message = "expectedVersion must be non-negative") |
|||
private Long expectedVersion; |
|||
} |
|||
@ -0,0 +1,51 @@ |
|||
-- Flyway V38: 企业岗位评价官方回复(功能版本 V40) |
|||
-- 仅新增回复事实与生命周期历史,不修改 V1-V37 已执行迁移。 |
|||
|
|||
CREATE TABLE company_review_replies ( |
|||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
|||
review_id BIGINT NOT NULL, |
|||
company_id BIGINT NOT NULL, |
|||
content VARCHAR(500) NOT NULL, |
|||
idempotency_key VARCHAR(64) NOT NULL, |
|||
moderation_status VARCHAR(20) NOT NULL DEFAULT 'PENDING', |
|||
moderation_reason VARCHAR(500), |
|||
moderation_score DECIMAL(5,2), |
|||
risk_level VARCHAR(20), |
|||
reviewed_by_user_id BIGINT NULL, |
|||
reviewed_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_company_review_replies_review UNIQUE (review_id), |
|||
CONSTRAINT uk_company_review_replies_idempotency UNIQUE (company_id, idempotency_key), |
|||
CONSTRAINT fk_company_review_replies_review FOREIGN KEY (review_id) |
|||
REFERENCES job_reviews(id) ON DELETE CASCADE, |
|||
CONSTRAINT fk_company_review_replies_company FOREIGN KEY (company_id) |
|||
REFERENCES companies(id), |
|||
CONSTRAINT fk_company_review_replies_reviewer FOREIGN KEY (reviewed_by_user_id) |
|||
REFERENCES users(id), |
|||
CONSTRAINT ck_company_review_replies_status CHECK ( |
|||
moderation_status IN ('AUTO_APPROVED', 'PENDING', 'APPROVED', 'REJECTED') |
|||
), |
|||
INDEX idx_company_review_replies_company_status_updated (company_id, moderation_status, updated_at), |
|||
INDEX idx_company_review_replies_status_created (moderation_status, created_at), |
|||
INDEX idx_company_review_replies_review (review_id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE company_review_reply_history ( |
|||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
|||
reply_id BIGINT NOT NULL, |
|||
action VARCHAR(30) NOT NULL, |
|||
actor_user_id BIGINT NULL, |
|||
reason_codes VARCHAR(500), |
|||
human_reason VARCHAR(500), |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
|||
CONSTRAINT fk_company_review_reply_history_reply FOREIGN KEY (reply_id) |
|||
REFERENCES company_review_replies(id) ON DELETE CASCADE, |
|||
CONSTRAINT fk_company_review_reply_history_actor FOREIGN KEY (actor_user_id) |
|||
REFERENCES users(id), |
|||
CONSTRAINT ck_company_review_reply_history_action CHECK ( |
|||
action IN ('CREATED', 'AUTO_APPROVED', 'PENDING', 'RESUBMITTED', 'APPROVED', 'REJECTED') |
|||
), |
|||
INDEX idx_company_review_reply_history_reply_created (reply_id, created_at) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
@ -0,0 +1,111 @@ |
|||
package com.mydreamjob.review; |
|||
|
|||
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.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
import com.mydreamjob.review.dto.ReviewReplyDecisionRequest; |
|||
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.HashSet; |
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
import java.util.Set; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertThrows; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.*; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
@MockitoSettings(strictness = Strictness.LENIENT) |
|||
class AdminReviewReplyServiceTest { |
|||
|
|||
@Mock private UserRepository userRepository; |
|||
@Mock private CompanyReviewReplyRepository replyRepository; |
|||
@Mock private CompanyReviewReplyHistoryRepository historyRepository; |
|||
@Mock private JobReviewRepository reviewRepository; |
|||
@Mock private JobRepository jobRepository; |
|||
@Mock private CompanyRepository companyRepository; |
|||
@Mock private AuditService auditService; |
|||
@Spy private com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); |
|||
@InjectMocks private AdminReviewReplyService service; |
|||
|
|||
private static final long ADMIN_ID = 1L; |
|||
private static final long REPLY_ID = 10L; |
|||
private static final long REVIEW_ID = 20L; |
|||
private static final long JOB_ID = 30L; |
|||
private static final long COMPANY_ID = 40L; |
|||
private CompanyReviewReply pending; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
User admin = User.builder().id(ADMIN_ID).role(User.Role.ADMIN).active(true).build(); |
|||
admin.setRoles(new HashSet<>(Set.of(User.Role.ADMIN))); |
|||
when(userRepository.findById(ADMIN_ID)).thenReturn(Optional.of(admin)); |
|||
pending = CompanyReviewReply.builder().id(REPLY_ID).reviewId(REVIEW_ID).companyId(COMPANY_ID) |
|||
.content("感谢反馈").moderationStatus(CompanyReviewReply.PENDING).version(0L) |
|||
.moderationReason("[]").build(); |
|||
when(replyRepository.findById(REPLY_ID)).thenReturn(Optional.of(pending)); |
|||
when(replyRepository.decidePending(eq(REPLY_ID), eq(0L), any(), eq(ADMIN_ID), any())).thenReturn(1); |
|||
when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of( |
|||
JobReview.builder().id(REVIEW_ID).jobId(JOB_ID).rating(4).content("评价") |
|||
.moderationStatus(JobReview.MODERATION_AUTO_APPROVED).build())); |
|||
when(jobRepository.findById(JOB_ID)).thenReturn(Optional.of(Job.builder().id(JOB_ID).companyId(COMPANY_ID) |
|||
.title("后端工程师").build())); |
|||
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(Company.builder().id(COMPANY_ID) |
|||
.name("Acme").build())); |
|||
} |
|||
|
|||
private ReviewReplyDecisionRequest request(String decision, String reason) { |
|||
ReviewReplyDecisionRequest req = new ReviewReplyDecisionRequest(); |
|||
req.setDecision(decision); |
|||
req.setReason(reason); |
|||
return req; |
|||
} |
|||
|
|||
@Test |
|||
void adminCanApprovePendingReplyAndWritesOneHistoryAndAudit() { |
|||
when(replyRepository.findById(REPLY_ID)).thenReturn(Optional.of(pending), Optional.of( |
|||
CompanyReviewReply.builder().id(REPLY_ID).reviewId(REVIEW_ID).companyId(COMPANY_ID) |
|||
.content("感谢反馈").moderationStatus(CompanyReviewReply.APPROVED).version(1L) |
|||
.moderationReason("[]").build())); |
|||
|
|||
var response = service.decide(ADMIN_ID, REPLY_ID, request("APPROVE", null), null); |
|||
|
|||
assertEquals(CompanyReviewReply.APPROVED, response.getReplyStatus()); |
|||
verify(historyRepository, times(1)).save(any()); |
|||
verify(auditService, times(1)).log(any(), eq("REVIEW_REPLY_APPROVED"), any(), any(), any(), any()); |
|||
} |
|||
|
|||
@Test |
|||
void rejectRequiresHumanReason() { |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.decide(ADMIN_ID, REPLY_ID, request("REJECT", " "), null)); |
|||
assertEquals(ErrorCode.REVIEW_REPLY_DECISION_INVALID.getCode(), ex.getErrorCodeInt()); |
|||
verify(replyRepository, never()).decidePending(any(), any(), any(), any(), any()); |
|||
} |
|||
|
|||
@Test |
|||
void nonAdminCannotDecide() { |
|||
User company = User.builder().id(2L).role(User.Role.COMPANY).active(true).build(); |
|||
company.setRoles(new HashSet<>(Set.of(User.Role.COMPANY))); |
|||
when(userRepository.findById(2L)).thenReturn(Optional.of(company)); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.decide(2L, REPLY_ID, request("APPROVE", null), null)); |
|||
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
} |
|||
@ -0,0 +1,62 @@ |
|||
package com.mydreamjob.review; |
|||
|
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.auth.JwtTokenProvider; |
|||
import com.mydreamjob.review.dto.ReviewReplyPageResponse; |
|||
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.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.eq; |
|||
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.status; |
|||
|
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
class CompanyReviewManagementControllerTest { |
|||
|
|||
@Autowired private MockMvc mockMvc; |
|||
@Autowired private JwtTokenProvider jwtTokenProvider; |
|||
@MockBean private CompanyReviewManagementService managementService; |
|||
@MockBean private CompanyReviewReplyService replyService; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private String token(long id, String role) { |
|||
return "Bearer " + jwtTokenProvider.generateAccessToken(id, role.equals("COMPANY") ? "1" : "2", |
|||
Map.of("roles", role)); |
|||
} |
|||
|
|||
@Test |
|||
void unauthenticatedCannotAccessCompanyReviews() throws Exception { |
|||
mockMvc.perform(get("/companies/me/job-reviews")) |
|||
.andExpect(status().isUnauthorized()); |
|||
} |
|||
|
|||
@Test |
|||
void studentCannotAccessCompanyReviews() throws Exception { |
|||
when(managementService.listMine(eq(2L), any(), any(), any(), any(), any(), any(), eq(0), eq(20))) |
|||
.thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); |
|||
mockMvc.perform(get("/companies/me/job-reviews").header("Authorization", token(2L, "STUDENT"))) |
|||
.andExpect(status().isForbidden()); |
|||
} |
|||
|
|||
@Test |
|||
void companyCanListItsReviews() throws Exception { |
|||
when(managementService.listMine(eq(1L), any(), any(), any(), any(), any(), any(), eq(0), eq(20))) |
|||
.thenReturn(ReviewReplyPageResponse.builder().items(java.util.List.of()).total(0) |
|||
.page(0).size(20).totalPages(0).build()); |
|||
mockMvc.perform(get("/companies/me/job-reviews").header("Authorization", token(1L, "COMPANY"))) |
|||
.andExpect(status().isOk()); |
|||
} |
|||
} |
|||
@ -0,0 +1,59 @@ |
|||
package com.mydreamjob.review; |
|||
|
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyService; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
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.data.domain.PageImpl; |
|||
import org.springframework.data.domain.PageRequest; |
|||
|
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.Mockito.*; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
class CompanyReviewManagementServiceTest { |
|||
|
|||
@Mock private CompanyService companyService; |
|||
@Mock private JobReviewRepository reviewRepository; |
|||
@Mock private JobRepository jobRepository; |
|||
@Mock private JobReviewDimensionScoreRepository dimensionScoreRepository; |
|||
@Mock private CompanyReviewReplyRepository replyRepository; |
|||
@Mock private CompanyReviewReplyHistoryRepository historyRepository; |
|||
@InjectMocks private CompanyReviewManagementService service; |
|||
|
|||
@Test |
|||
void companyListUsesBatchLoadsInsteadOfPerItemQueries() { |
|||
Company company = Company.builder().id(50L).ownerUserId(7L).name("Acme").build(); |
|||
JobReview first = JobReview.builder().id(1L).jobId(10L).rating(4).content("a") |
|||
.moderationStatus(JobReview.MODERATION_AUTO_APPROVED).build(); |
|||
JobReview second = JobReview.builder().id(2L).jobId(11L).rating(5).content("b") |
|||
.moderationStatus(JobReview.MODERATION_APPROVED).build(); |
|||
when(companyService.findCompanyByOwnerUserId(7L)).thenReturn(company); |
|||
when(reviewRepository.findCompanyReviews(eq(50L), any(), any(), any(), any(), any(), any(), any())) |
|||
.thenReturn(new PageImpl<>(List.of(first, second), PageRequest.of(0, 20), 2)); |
|||
when(jobRepository.findAllById(any())).thenReturn(List.of( |
|||
Job.builder().id(10L).companyId(50L).title("后端").build(), |
|||
Job.builder().id(11L).companyId(50L).title("前端").build())); |
|||
when(dimensionScoreRepository.findByReviewIdIn(any())).thenReturn(List.of()); |
|||
when(replyRepository.findByReviewIdIn(any())).thenReturn(List.of()); |
|||
|
|||
var result = service.listMine(7L, null, null, null, null, null, null, 0, 20); |
|||
|
|||
assertEquals(2, result.getItems().size()); |
|||
verify(jobRepository, times(1)).findAllById(any()); |
|||
verify(jobRepository, never()).findById(any()); |
|||
verify(dimensionScoreRepository, times(1)).findByReviewIdIn(any()); |
|||
verify(replyRepository, times(1)).findByReviewIdIn(any()); |
|||
} |
|||
} |
|||
@ -0,0 +1,228 @@ |
|||
package com.mydreamjob.review; |
|||
|
|||
import com.mydreamjob.audit.AuditContext; |
|||
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.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
import com.mydreamjob.review.dto.CreateCompanyReviewReplyRequest; |
|||
import com.mydreamjob.review.dto.ReviewReplyDecisionRequest; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
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.jdbc.core.JdbcTemplate; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.HashSet; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.UUID; |
|||
import java.util.concurrent.ConcurrentLinkedQueue; |
|||
import java.util.concurrent.CountDownLatch; |
|||
import java.util.concurrent.ExecutorService; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.concurrent.TimeUnit; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertTrue; |
|||
|
|||
/** H2 + Flyway schema smoke test for V40; no Docker/MySQL is started here. */ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class CompanyReviewReplyPersistenceIntegrationTest { |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
@Autowired private CompanyReviewReplyService replyService; |
|||
@Autowired private AdminReviewReplyService adminReviewReplyService; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private CompanyRepository companyRepository; |
|||
@Autowired private JobRepository jobRepository; |
|||
@Autowired private JobReviewRepository reviewRepository; |
|||
@Autowired private CompanyReviewReplyRepository replyRepository; |
|||
@Autowired private AuditLogRepository auditLogRepository; |
|||
|
|||
private static final AtomicLong PHONE = new AtomicLong(6200); |
|||
private final List<Long> cleanupUsers = new ArrayList<>(); |
|||
private final List<Long> cleanupCompanies = new ArrayList<>(); |
|||
private final List<Long> cleanupJobs = new ArrayList<>(); |
|||
private final List<Long> cleanupReviews = new ArrayList<>(); |
|||
private final List<Long> cleanupReplies = new ArrayList<>(); |
|||
|
|||
@AfterEach |
|||
void cleanupFixtures() { |
|||
if (!cleanupUsers.isEmpty() || !cleanupReplies.isEmpty()) { |
|||
List<AuditLog> logs = auditLogRepository.findAll().stream() |
|||
.filter(log -> cleanupUsers.contains(log.getUserId()) || cleanupReplies.contains(log.getEntityId())) |
|||
.toList(); |
|||
auditLogRepository.deleteAll(logs); |
|||
} |
|||
if (!cleanupReplies.isEmpty()) replyRepository.deleteAllById(cleanupReplies); |
|||
if (!cleanupReviews.isEmpty()) reviewRepository.deleteAllById(cleanupReviews); |
|||
if (!cleanupJobs.isEmpty()) jobRepository.deleteAllById(cleanupJobs); |
|||
if (!cleanupCompanies.isEmpty()) companyRepository.deleteAllById(cleanupCompanies); |
|||
if (!cleanupUsers.isEmpty()) userRepository.deleteAllById(cleanupUsers); |
|||
cleanupReplies.clear(); |
|||
cleanupReviews.clear(); |
|||
cleanupJobs.clear(); |
|||
cleanupCompanies.clear(); |
|||
cleanupUsers.clear(); |
|||
} |
|||
|
|||
@Test |
|||
void v40TablesAndCriticalConstraintsArePresent() { |
|||
assertEquals(1, count("SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME='COMPANY_REVIEW_REPLIES'")); |
|||
assertEquals(1, count("SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME='COMPANY_REVIEW_REPLY_HISTORY'")); |
|||
assertEquals(1, count("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='COMPANY_REVIEW_REPLIES' AND COLUMN_NAME='IDEMPOTENCY_KEY'")); |
|||
assertEquals(1, count("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='COMPANY_REVIEW_REPLIES' AND COLUMN_NAME='VERSION'")); |
|||
assertEquals(1, count("SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE TABLE_NAME='COMPANY_REVIEW_REPLIES' AND CONSTRAINT_NAME='UK_COMPANY_REVIEW_REPLIES_REVIEW' AND CONSTRAINT_TYPE='UNIQUE'")); |
|||
assertEquals(1, count("SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE TABLE_NAME='COMPANY_REVIEW_REPLIES' AND CONSTRAINT_NAME='UK_COMPANY_REVIEW_REPLIES_IDEMPOTENCY' AND CONSTRAINT_TYPE='UNIQUE'")); |
|||
assertEquals(1, count("SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE TABLE_NAME='COMPANY_REVIEW_REPLY_HISTORY' AND CONSTRAINT_NAME='CK_COMPANY_REVIEW_REPLY_HISTORY_ACTION' AND CONSTRAINT_TYPE='CHECK'")); |
|||
assertEquals(3, count("SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE TABLE_NAME='COMPANY_REVIEW_REPLIES' AND CONSTRAINT_TYPE='FOREIGN KEY'")); |
|||
} |
|||
|
|||
@Test |
|||
void concurrentCreate_hasOneWinner_andNoUnexpected500() throws Exception { |
|||
Fixture fixture = fixture(); |
|||
int n = 4; |
|||
ExecutorService pool = Executors.newFixedThreadPool(n); |
|||
CountDownLatch start = new CountDownLatch(1); |
|||
CountDownLatch done = new CountDownLatch(n); |
|||
ConcurrentLinkedQueue<Integer> outcomes = new ConcurrentLinkedQueue<>(); |
|||
for (int i = 0; i < n; i++) { |
|||
final String key = UUID.randomUUID().toString(); |
|||
pool.submit(() -> { |
|||
await(start); |
|||
try { |
|||
replyService.create(fixture.companyUserId, fixture.reviewId, |
|||
createRequest("感谢您的反馈", key), AuditContext.builder().requestId("v40").build()); |
|||
outcomes.add(0); |
|||
} catch (BusinessException ex) { |
|||
outcomes.add(ex.getErrorCodeInt()); |
|||
} catch (Exception ex) { |
|||
outcomes.add(-1); |
|||
} finally { |
|||
done.countDown(); |
|||
} |
|||
}); |
|||
} |
|||
start.countDown(); |
|||
assertTrue(done.await(20, TimeUnit.SECONDS)); |
|||
pool.shutdownNow(); |
|||
assertEquals(1, outcomes.stream().filter(code -> code == 0).count()); |
|||
assertEquals(n - 1, outcomes.stream().filter(code -> code == 10102).count()); |
|||
assertEquals(0, outcomes.stream().filter(code -> code < 0).count()); |
|||
assertEquals(1, replyRepository.findByReviewId(fixture.reviewId).stream().count()); |
|||
} |
|||
|
|||
@Test |
|||
void concurrentAdminDecision_hasOneWinner_andOneHistory() throws Exception { |
|||
Fixture fixture = fixture(); |
|||
User admin = userRepository.save(User.builder() |
|||
.phone(phone()).nickname("v40-admin").role(User.Role.ADMIN) |
|||
.roles(new HashSet<>(Set.of(User.Role.ADMIN))).active(true).verified(true).build()); |
|||
cleanupUsers.add(admin.getId()); |
|||
replyService.create(fixture.companyUserId, fixture.reviewId, |
|||
createRequest("请联系 13800138000", UUID.randomUUID().toString()), |
|||
AuditContext.builder().requestId("v40").build()); |
|||
CompanyReviewReply reply = replyRepository.findByReviewId(fixture.reviewId).orElseThrow(); |
|||
cleanupReplies.add(reply.getId()); |
|||
|
|||
int n = 2; |
|||
ExecutorService pool = Executors.newFixedThreadPool(n); |
|||
CountDownLatch start = new CountDownLatch(1); |
|||
CountDownLatch done = new CountDownLatch(n); |
|||
ConcurrentLinkedQueue<Integer> outcomes = new ConcurrentLinkedQueue<>(); |
|||
for (int i = 0; i < n; i++) { |
|||
pool.submit(() -> { |
|||
await(start); |
|||
try { |
|||
adminReviewReplyService.decide(admin.getId(), reply.getId(), decisionRequest(), |
|||
AuditContext.builder().requestId("v40").build()); |
|||
outcomes.add(0); |
|||
} catch (BusinessException ex) { |
|||
outcomes.add(ex.getErrorCodeInt()); |
|||
} catch (Exception ex) { |
|||
outcomes.add(-1); |
|||
} finally { |
|||
done.countDown(); |
|||
} |
|||
}); |
|||
} |
|||
start.countDown(); |
|||
assertTrue(done.await(20, TimeUnit.SECONDS)); |
|||
pool.shutdownNow(); |
|||
assertEquals(1, outcomes.stream().filter(code -> code == 0).count()); |
|||
assertEquals(1, outcomes.stream().filter(code -> code == 10103 || code == 10104).count()); |
|||
assertEquals(0, outcomes.stream().filter(code -> code < 0).count()); |
|||
assertEquals(1, replyRepository.findById(reply.getId()).orElseThrow().getModerationStatus().equals(CompanyReviewReply.APPROVED) ? 1 : 0); |
|||
long approvedAudits = auditLogRepository.findAll().stream() |
|||
.filter(a -> "REVIEW_REPLY_APPROVED".equals(a.getAction()) && reply.getId().equals(a.getEntityId())).count(); |
|||
assertEquals(1, approvedAudits); |
|||
} |
|||
|
|||
private CreateCompanyReviewReplyRequest createRequest(String content, String key) { |
|||
CreateCompanyReviewReplyRequest request = new CreateCompanyReviewReplyRequest(); |
|||
request.setContent(content); |
|||
request.setIdempotencyKey(key); |
|||
return request; |
|||
} |
|||
|
|||
private ReviewReplyDecisionRequest decisionRequest() { |
|||
ReviewReplyDecisionRequest request = new ReviewReplyDecisionRequest(); |
|||
request.setDecision("APPROVE"); |
|||
return request; |
|||
} |
|||
|
|||
private Fixture fixture() { |
|||
User companyUser = userRepository.save(User.builder().phone(phone()).nickname("v40-company") |
|||
.role(User.Role.COMPANY).roles(new HashSet<>(Set.of(User.Role.COMPANY))) |
|||
.active(true).verified(true).build()); |
|||
User student = userRepository.save(User.builder().phone(phone()).nickname("v40-student") |
|||
.role(User.Role.STUDENT).roles(new HashSet<>(Set.of(User.Role.STUDENT))) |
|||
.active(true).verified(true).build()); |
|||
cleanupUsers.add(companyUser.getId()); |
|||
cleanupUsers.add(student.getId()); |
|||
Company company = companyRepository.save(Company.builder().ownerUserId(companyUser.getId()) |
|||
.name("V40 Company").verified(true).build()); |
|||
Job job = jobRepository.save(Job.builder().companyId(company.getId()).title("V40 Job") |
|||
.description("job").city("Shanghai").employmentType("FULL_TIME").status("PUBLISHED").build()); |
|||
JobReview review = reviewRepository.save(JobReview.builder().jobId(job.getId()).studentUserId(student.getId()) |
|||
.rating(5).content("review").status("PUBLISHED") |
|||
.moderationStatus(JobReview.MODERATION_AUTO_APPROVED).build()); |
|||
cleanupCompanies.add(company.getId()); |
|||
cleanupJobs.add(job.getId()); |
|||
cleanupReviews.add(review.getId()); |
|||
return new Fixture(companyUser.getId(), review.getId()); |
|||
} |
|||
|
|||
private String phone() { |
|||
return "18" + String.format("%09d", PHONE.incrementAndGet()); |
|||
} |
|||
|
|||
private void await(CountDownLatch latch) { |
|||
try { |
|||
latch.await(); |
|||
} catch (InterruptedException e) { |
|||
Thread.currentThread().interrupt(); |
|||
} |
|||
} |
|||
|
|||
private record Fixture(Long companyUserId, Long reviewId) { } |
|||
|
|||
private int count(String sql) { |
|||
Integer value = jdbcTemplate.queryForObject(sql, Integer.class); |
|||
return value == null ? 0 : value; |
|||
} |
|||
} |
|||
@ -0,0 +1,151 @@ |
|||
package com.mydreamjob.review; |
|||
|
|||
import com.mydreamjob.audit.AuditService; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyService; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
import com.mydreamjob.review.dto.CreateCompanyReviewReplyRequest; |
|||
import com.mydreamjob.review.dto.UpdateCompanyReviewReplyRequest; |
|||
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 java.util.UUID; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertThrows; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.*; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
@MockitoSettings(strictness = Strictness.LENIENT) |
|||
class CompanyReviewReplyServiceTest { |
|||
|
|||
@Mock private CompanyService companyService; |
|||
@Mock private JobReviewRepository reviewRepository; |
|||
@Mock private JobRepository jobRepository; |
|||
@Mock private CompanyReviewReplyRepository replyRepository; |
|||
@Mock private CompanyReviewReplyHistoryRepository historyRepository; |
|||
@Mock private ReviewModerationService moderationService; |
|||
@Mock private AuditService auditService; |
|||
@Mock private com.fasterxml.jackson.databind.ObjectMapper objectMapper; |
|||
@InjectMocks private CompanyReviewReplyService service; |
|||
|
|||
private static final long USER_ID = 11L; |
|||
private static final long COMPANY_ID = 21L; |
|||
private static final long JOB_ID = 31L; |
|||
private static final long REVIEW_ID = 41L; |
|||
private final String key = UUID.randomUUID().toString(); |
|||
private Company company; |
|||
private JobReview review; |
|||
private Job job; |
|||
|
|||
@BeforeEach |
|||
void setUp() throws Exception { |
|||
company = Company.builder().id(COMPANY_ID).ownerUserId(USER_ID).name("Acme").build(); |
|||
review = JobReview.builder().id(REVIEW_ID).jobId(JOB_ID).studentUserId(99L) |
|||
.rating(4).content("评价").status("PUBLISHED").build(); |
|||
job = Job.builder().id(JOB_ID).companyId(COMPANY_ID).title("后端工程师").build(); |
|||
when(companyService.findCompanyByOwnerUserId(USER_ID)).thenReturn(company); |
|||
when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(review)); |
|||
when(jobRepository.findById(JOB_ID)).thenReturn(Optional.of(job)); |
|||
when(replyRepository.findByCompanyIdAndIdempotencyKey(any(), any())).thenReturn(Optional.empty()); |
|||
when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.empty()); |
|||
when(objectMapper.writeValueAsString(any())).thenReturn("[]"); |
|||
when(replyRepository.saveAndFlush(any())).thenAnswer(invocation -> { |
|||
CompanyReviewReply reply = invocation.getArgument(0); |
|||
if (reply.getId() == null) reply.setId(501L); |
|||
return reply; |
|||
}); |
|||
} |
|||
|
|||
private ReviewModerationService.ModerationResult autoApproved() { |
|||
return new ReviewModerationService.ModerationResult( |
|||
CompanyReviewReply.AUTO_APPROVED, List.of(), BigDecimal.ZERO, null); |
|||
} |
|||
|
|||
private ReviewModerationService.ModerationResult pending() { |
|||
return new ReviewModerationService.ModerationResult( |
|||
CompanyReviewReply.PENDING, List.of(ReviewModerationService.REASON_CONTACT_INFO), |
|||
BigDecimal.valueOf(2), "MEDIUM"); |
|||
} |
|||
|
|||
private CreateCompanyReviewReplyRequest createRequest(String content) { |
|||
CreateCompanyReviewReplyRequest request = new CreateCompanyReviewReplyRequest(); |
|||
request.setContent(content); |
|||
request.setIdempotencyKey(key); |
|||
return request; |
|||
} |
|||
|
|||
@Test |
|||
void safeTextIsAutoApprovedAndAudited() { |
|||
when(moderationService.moderateCompanyReply("感谢反馈")).thenReturn(autoApproved()); |
|||
|
|||
var response = service.create(USER_ID, REVIEW_ID, createRequest("感谢反馈"), null); |
|||
|
|||
assertEquals(CompanyReviewReply.AUTO_APPROVED, response.getStatus()); |
|||
verify(historyRepository, times(2)).save(any()); |
|||
verify(auditService, times(2)).log(any(), any(), any(), any(), any(), any()); |
|||
} |
|||
|
|||
@Test |
|||
void riskyTextBecomesPendingWithoutExposingReasonToCompany() { |
|||
when(moderationService.moderateCompanyReply("请联系 13800138000")).thenReturn(pending()); |
|||
|
|||
var response = service.create(USER_ID, REVIEW_ID, createRequest("请联系 13800138000"), null); |
|||
|
|||
assertEquals(CompanyReviewReply.PENDING, response.getStatus()); |
|||
assertEquals(null, response.getRejectionReason()); |
|||
verify(auditService).log(any(), eq("COMPANY_REVIEW_REPLY_CREATED"), any(), any(), any(), any()); |
|||
} |
|||
|
|||
@Test |
|||
void existingReplyCannotBeCreatedAgain() { |
|||
when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of( |
|||
CompanyReviewReply.builder().id(501L).reviewId(REVIEW_ID) |
|||
.companyId(COMPANY_ID).moderationStatus(CompanyReviewReply.PENDING).content("旧回复").build())); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.create(USER_ID, REVIEW_ID, createRequest("新回复"), null)); |
|||
assertEquals(ErrorCode.REVIEW_REPLY_ALREADY_EXISTS.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void rejectedReplyCanBeResubmittedWithExpectedVersion() { |
|||
CompanyReviewReply rejected = CompanyReviewReply.builder().id(501L).reviewId(REVIEW_ID) |
|||
.companyId(COMPANY_ID).content("旧回复").moderationStatus(CompanyReviewReply.REJECTED) |
|||
.version(2L).build(); |
|||
when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(rejected)); |
|||
when(moderationService.moderateCompanyReply("修订后回复")).thenReturn(autoApproved()); |
|||
UpdateCompanyReviewReplyRequest request = new UpdateCompanyReviewReplyRequest(); |
|||
request.setContent("修订后回复"); |
|||
request.setExpectedVersion(2L); |
|||
|
|||
var response = service.revise(USER_ID, REVIEW_ID, request, null); |
|||
|
|||
assertEquals("修订后回复", response.getContent()); |
|||
assertEquals(CompanyReviewReply.AUTO_APPROVED, response.getStatus()); |
|||
verify(historyRepository, times(2)).save(any()); |
|||
verify(auditService).log(any(), eq("COMPANY_REVIEW_REPLY_RESUBMITTED"), any(), any(), any(), any()); |
|||
} |
|||
|
|||
@Test |
|||
void idempotencyKeyWithDifferentPayloadIsConflict() { |
|||
when(replyRepository.findByCompanyIdAndIdempotencyKey(COMPANY_ID, key)).thenReturn(Optional.of( |
|||
CompanyReviewReply.builder().id(501L).reviewId(999L).companyId(COMPANY_ID) |
|||
.content("其他内容").build())); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> service.create(USER_ID, REVIEW_ID, createRequest("感谢反馈"), null)); |
|||
assertEquals(ErrorCode.REVIEW_REPLY_IDEMPOTENCY_CONFLICT.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
} |
|||
@ -0,0 +1,79 @@ |
|||
package com.mydreamjob.review; |
|||
|
|||
import com.mydreamjob.audit.AuditService; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobService; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
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.data.domain.PageImpl; |
|||
|
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Optional; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertFalse; |
|||
import static org.junit.jupiter.api.Assertions.assertTrue; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
class JobReviewPublicReplyTest { |
|||
|
|||
@Mock private JobReviewRepository reviewRepository; |
|||
@Mock private JobReviewDimensionScoreRepository dimensionScoreRepository; |
|||
@Mock private ReviewModerationHistoryRepository moderationHistoryRepository; |
|||
@Mock private ReviewModerationService moderationService; |
|||
@Mock private JobService jobService; |
|||
@Mock private UserRepository userRepository; |
|||
@Mock private AuditService auditService; |
|||
@Mock private CompanyReviewReplyRepository replyRepository; |
|||
@InjectMocks private JobReviewService reviewService; |
|||
|
|||
@Test |
|||
void publicReviewRedactsStudentIdAndOnlyShowsApprovedOfficialReply() throws Exception { |
|||
JobReview review = JobReview.builder().id(1L).jobId(9L).studentUserId(777L) |
|||
.rating(5).content("公开评价").status("PUBLISHED") |
|||
.moderationStatus(JobReview.MODERATION_AUTO_APPROVED).build(); |
|||
CompanyReviewReply reply = CompanyReviewReply.builder().id(2L).reviewId(1L).companyId(3L) |
|||
.content("感谢反馈").moderationStatus(CompanyReviewReply.APPROVED).build(); |
|||
when(jobService.getJob(9L)).thenReturn(Job.builder().id(9L).build()); |
|||
when(reviewRepository.findByJobIdAndStatusAndModerationStatusInOrderByCreatedAtDesc( |
|||
eq(9L), eq("PUBLISHED"), any(), any())).thenReturn(new PageImpl<>(List.of(review))); |
|||
when(dimensionScoreRepository.findByReviewIdIn(any())).thenReturn(List.of()); |
|||
when(replyRepository.findByReviewIdIn(any())).thenReturn(List.of(reply)); |
|||
|
|||
Map<String, Object> view = reviewService.listReviews(9L, 0, 20).getContent().get(0); |
|||
String json = new ObjectMapper().writeValueAsString(view); |
|||
|
|||
assertFalse(view.containsKey("studentUserId")); |
|||
assertTrue(view.containsKey("reviewerLabel")); |
|||
assertTrue(view.containsKey("officialReply")); |
|||
assertFalse(json.contains("studentUserId")); |
|||
assertFalse(json.contains("companyId")); |
|||
assertFalse(json.contains("replyId")); |
|||
} |
|||
|
|||
@Test |
|||
void pendingOfficialReplyIsNotPublic() { |
|||
JobReview review = JobReview.builder().id(1L).jobId(9L).studentUserId(777L) |
|||
.rating(5).content("公开评价").status("PUBLISHED") |
|||
.moderationStatus(JobReview.MODERATION_AUTO_APPROVED).build(); |
|||
CompanyReviewReply reply = CompanyReviewReply.builder().id(2L).reviewId(1L).companyId(3L) |
|||
.content("待审核").moderationStatus(CompanyReviewReply.PENDING).build(); |
|||
when(jobService.getJob(9L)).thenReturn(Job.builder().id(9L).build()); |
|||
when(reviewRepository.findByJobIdAndStatusAndModerationStatusInOrderByCreatedAtDesc( |
|||
eq(9L), eq("PUBLISHED"), any(), any())).thenReturn(new PageImpl<>(List.of(review))); |
|||
when(dimensionScoreRepository.findByReviewIdIn(any())).thenReturn(List.of()); |
|||
when(replyRepository.findByReviewIdIn(any())).thenReturn(List.of(reply)); |
|||
|
|||
Map<String, Object> view = reviewService.listReviews(9L, 0, 20).getContent().get(0); |
|||
|
|||
assertFalse(view.containsKey("officialReply")); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue