Browse Source

feat: deliver V40 company review replies

feat/message-recall
Zhang Shun 2 months ago
parent
commit
2698d064ae
  1. 13
      docs/backend-development-roadmap.md
  2. 111
      docs/company-review-management-api.md
  3. 44
      docs/frontend-integration.md
  4. 22
      docs/openapi-rc.json
  5. 357
      docs/v40-company-review-management-design.md
  6. 51
      docs/verify_openapi.py
  7. 11
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  8. 9
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
  9. 78
      mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyController.java
  10. 204
      mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyService.java
  11. 102
      mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementController.java
  12. 196
      mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementService.java
  13. 90
      mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReply.java
  14. 59
      mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyHistory.java
  15. 19
      mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyHistoryRepository.java
  16. 64
      mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyRepository.java
  17. 237
      mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyService.java
  18. 24
      mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyWriter.java
  19. 32
      mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java
  20. 36
      mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java
  21. 28
      mydreamjob-server/src/main/java/com/mydreamjob/review/ReviewModerationService.java
  22. 20
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/AdminReviewReplyPageResponse.java
  23. 33
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/AdminReviewReplyResponse.java
  24. 8
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewDetailResponse.java
  25. 26
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewItemResponse.java
  26. 13
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewJobResponse.java
  27. 21
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyResponse.java
  28. 16
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CreateCompanyReviewReplyRequest.java
  29. 14
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewReplyDecisionRequest.java
  30. 20
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewReplyPageResponse.java
  31. 16
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/UpdateCompanyReviewReplyRequest.java
  32. 51
      mydreamjob-server/src/main/resources/db/migration/V38__company_review_replies.sql
  33. 111
      mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewReplyServiceTest.java
  34. 62
      mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementControllerTest.java
  35. 59
      mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementServiceTest.java
  36. 228
      mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyPersistenceIntegrationTest.java
  37. 151
      mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyServiceTest.java
  38. 79
      mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewPublicReplyTest.java

13
docs/backend-development-roadmap.md

@ -26,11 +26,11 @@
| 项目 | 当前状态 | | 项目 | 当前状态 |
|---|---| |---|---|
| OpenAPI | **154 paths、190 operations**(V39 已完成,待 Git 检查点) | | OpenAPI | **159 paths、196 operations**(V40 静态与运行时校验通过) |
| Flyway | **V1–V37 已应用**;V37 为岗位推荐分类、画像与学生反馈迁移(功能版本 V39,真实 MySQL/Docker 关键黑盒已通过) | | Flyway | **V1–V38 已成功执行**;V38 为企业评价官方回复迁移(功能版本 V40,真实 MySQL/Docker 已验收) |
| 全量测试 | **1428/1428 通过**(V39 自动化回归,0 Failures / 0 Errors / 0 Skipped) | | 全量测试 | V40 专项与全量测试、`mvn clean verify` 已通过 **1445/1445**;真实 MySQL/Docker 黑盒 `PASS=62/FAIL=0/SKIP=0` |
| 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,已真实验收)**、**V36 四维能力测评(Flyway V35)**、**V37 防刷风险概览与运营规则配置(Flyway V36)**、**V38 四维能力测评有限接入候选评分(无新 Flyway、无新公网接口)**,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 | | 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,已真实验收)**、**V36 四维能力测评(Flyway V35)**、**V37 防刷风险概览与运营规则配置(Flyway V36)**、**V38 四维能力测评有限接入候选评分(无新 Flyway、无新公网接口)**,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 |
| 当前结论 | V35 已通过真实 MySQL/Docker 验收(PASS=39/FAIL=0/SKIP=1;唯一 SKIP 为验收库无 INACTIVE 定义)。**V36 四维能力测评已通过真实 MySQL/Docker 验收(PASS=93/FAIL=0/SKIP=0);V37 防刷风险治理已通过真实 MySQL/Docker 验收(PASS=76/FAIL=0/SKIP=0)。** V37 修复前 4 条 `RISK_EVENT_DETECTED.entity_id=NULL` 审计只作历史观察项,不回填、不影响修复后验收。**V38 已完成代码、自动化与真实 MySQL/Docker 关键黑盒验收:开关关闭/开启、ABILITY 映射、最新结果优先、过期回退、V32 排名、权限、脱敏、快照与队列均通过,未发现 FAIL;当前仅待建立 Git 检查点。** **V39 测评驱动岗位推荐已完成真实 MySQL/Docker 关键黑盒:Flyway V37、3 张表/15 条分类、画像审核、FALLBACK/PERSONALIZED、反馈排除/撤销、最新能力结果优先、权限、脱敏、快照不写入和 RabbitMQ 队列均通过,未发现 FAIL;当前仅待建立 Git 检查点。** 运行时/静态 OpenAPI 均为 154/190。 | | 当前结论 | V35–V39 的既有验收结论保持不变。**V40 企业评价管理与官方回复已完成首版实现、专项/全量自动化测试、`mvn clean verify`、静态/运行时 OpenAPI 与真实 MySQL/Docker 黑盒验收(PASS=62/FAIL=0/SKIP=0);待建立 Git 检查点。** OpenAPI 为 159/196。 |
| 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 | | 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 |
### 2.0 模块状态分类 ### 2.0 模块状态分类
@ -39,7 +39,8 @@
|---|---| |---|---|
| 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,PASS=39/FAIL=0/SKIP=1)**、**V36 四维能力测评(Flyway V35,PASS=93/FAIL=0/SKIP=0)**、**V37 防刷风险概览与运营规则配置(Flyway V36,PASS=76/FAIL=0/SKIP=0)**、**V38 四维能力测评有限接入候选评分(无新 Flyway/公网接口,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)**、**V39 测评驱动岗位推荐(Flyway V37,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)** | | 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,PASS=39/FAIL=0/SKIP=1)**、**V36 四维能力测评(Flyway V35,PASS=93/FAIL=0/SKIP=0)**、**V37 防刷风险概览与运营规则配置(Flyway V36,PASS=76/FAIL=0/SKIP=0)**、**V38 四维能力测评有限接入候选评分(无新 Flyway/公网接口,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)**、**V39 测评驱动岗位推荐(Flyway V37,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)** |
| 已完成、待真实 MySQL/Docker 复验 | — | | 已完成、待真实 MySQL/Docker 复验 | — |
| 已设计未编码 | 公平性检测、SHAP/规则解释、企业评价管理、企业人才推荐 | | 真实黑盒已验收、待 Git 检查点 | **V40 企业评价管理与官方回复(Flyway V38,5 paths / 6 operations,PASS=62/FAIL=0/SKIP=0)** |
| 已设计未编码 | 公平性检测、SHAP/规则解释、企业人才推荐 |
| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 | | 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 |
### 2.1 已确认的产品决策 ### 2.1 已确认的产品决策
@ -459,7 +460,7 @@ scoring_risk_scan_checkpoints
| P0/P1 | 同岗位竞争力排名 | | P0/P1 | 同岗位竞争力排名 |
| P1 | 测评结果进入评分引擎、测评岗位推荐(V38/V39 已完成) | | P1 | 测评结果进入评分引擎、测评岗位推荐(V38/V39 已完成) |
| P1 | 统一求职进度(V34 已完成) | | P1 | 统一求职进度(V34 已完成) |
| P1 | 企业评价管理 | | P1 | ~~企业评价管理~~(V40 自动化、`clean verify` 与真实 MySQL/Docker 黑盒已通过,待 Git 检查点) |
| P1 | 企业人才推荐 | | P1 | 企业人才推荐 |
| P1 | 公平性检测 | | P1 | 公平性检测 |
| P1 | 公平性检测 | | P1 | 公平性检测 |

111
docs/company-review-management-api.md

@ -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 黑盒须单独执行并如实记录。

44
docs/frontend-integration.md

@ -862,6 +862,50 @@ POST /admin/scoring/risk/events/{eventId}/actions 人工标记(ACKNOWLEDGE/DI
- 规则/阈值的命中可能不会立刻出现:扫描默认每 60 秒跑一次(`app.risk.scan.fixed-delay-ms`)。 - 规则/阈值的命中可能不会立刻出现:扫描默认每 60 秒跑一次(`app.risk.scan.fixed-delay-ms`)。
- 概览/趋势/分布由后端 SQL 聚合返回,前端无需额外拉取明细。 - 概览/趋势/分布由后端 SQL 聚合返回,前端无需额外拉取明细。
## 24. V40 企业评价管理与官方回复
> 验收状态:真实 MySQL/Docker 黑盒已通过(`PASS=62 FAIL=0 SKIP=0`);OpenAPI 159/196,待 Git 检查点。
### 24.1 接口
```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
```
企业接口只返回当前登录企业自有岗位评价;身份从 JWT 获取,不传 `companyId`、`studentUserId`。STUDENT/ADMIN 为 `403/2002`,未登录为 `401`,跨企业访问不泄露评价是否存在。
### 24.2 回复状态与操作
- 创建回复正文 1–500 字符,`idempotencyKey` 必须为 UUID(最长 64);安全文本为 `AUTO_APPROVED`,风险文本为 `PENDING`。
- 同评价最多一个当前回复;同幂等键同评价同正文重放返回原回复,内容不一致为 `409/10106`,已有回复为 `409/10102`。
- 只有 `REJECTED` 回复可以 PUT 修订,必须携带 `expectedVersion`;`AUTO_APPROVED`、`APPROVED`、`PENDING` 不可覆盖或删除。版本冲突为 `409/10104`。
- ADMIN 仅可对 `PENDING` 执行 `APPROVE` 或 `REJECT`;驳回时 `reason` 必填(1–500 字符)。重复决策为 `409/10103`,非法决策为 `400/10107`。
企业列表/详情的 `reply` 只包含 `id/content/status/version/updatedAt/rejectionReason`。禁止依赖 `studentUserId`、学生姓名、联系方式、IP/UA、`moderationScore`、内部 `reasonCodes` 或管理员 ID。
### 24.3 公开评价与官方回复
既有 `/jobs/{jobId}/reviews` 与 `/jobs/{jobId}/reviews/{reviewId}` 的评价载荷不再包含 `studentUserId`,可显示固定 `reviewerLabel="匿名求职者"`。仅公开评价且回复状态为 `AUTO_APPROVED`/`APPROVED` 时显示:
```json
{"officialReply":{"content":"感谢反馈。","repliedAt":"2026-09-04T10:05:00"}}
```
`officialReply` 不含回复 ID、公司 ID、审核状态、版本、原因码或审核人信息;`PENDING`/`REJECTED` 不渲染该区域。前端应兼容字段缺省或 `null`。
### 24.4 管理员页面
待审列表可按公司、岗位、风险等级和时间筛选;管理员响应中的风险等级/原因码只用于后台审核,不得透传到公开评价。V40 不提供回复通知、统计、申诉或公开历史接口。
### 24.5 错误码
`10101` 回复不存在(404)、`10102` 已有回复(409)、`10103` 状态非法(409)、`10104` 并发冲突(409)、`10105` 正文/幂等键非法(400)、`10106` 幂等冲突(409)、`10107` 决策非法(400)。
## 24. V39 测评驱动岗位推荐(编码中) ## 24. V39 测评驱动岗位推荐(编码中)
- 开关 `RECOMMENDATION_ASSESSMENT_ENABLED` 默认 `false`:关闭时 `GET /profile/me/job-recommendations` 仅返回 `mode=FALLBACK` 的最新公开岗位,不展示伪造的测评匹配分。 - 开关 `RECOMMENDATION_ASSESSMENT_ENABLED` 默认 `false`:关闭时 `GET /profile/me/job-recommendations` 仅返回 `mode=FALLBACK` 的最新公开岗位,不展示伪造的测评匹配分。

22
docs/openapi-rc.json

@ -8054,7 +8054,12 @@
"/profile/me/job-recommendations/{jobId}/feedback": {"post":{"tags":["岗位推荐"],"operationId":"createRecommendationFeedback","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecommendationFeedbackRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseRecommendationFeedbackResponse"}}}}}},"delete":{"tags":["岗位推荐"],"operationId":"revokeNotInterestedFeedback","parameters":[{"name":"expectedVersion","in":"query","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK"}}}}, "/profile/me/job-recommendations/{jobId}/feedback": {"post":{"tags":["岗位推荐"],"operationId":"createRecommendationFeedback","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecommendationFeedbackRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseRecommendationFeedbackResponse"}}}}}},"delete":{"tags":["岗位推荐"],"operationId":"revokeNotInterestedFeedback","parameters":[{"name":"expectedVersion","in":"query","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK"}}}},
"/companies/me/jobs/{jobId}/recommendation-profile": {"get":{"tags":["岗位推荐"],"operationId":"companyRecommendationProfile","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseRecommendationProfileResponse"}}}}}},"put":{"tags":["岗位推荐"],"operationId":"updateCompanyRecommendationProfile","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecommendationProfileRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseRecommendationProfileResponse"}}}}}}}, "/companies/me/jobs/{jobId}/recommendation-profile": {"get":{"tags":["岗位推荐"],"operationId":"companyRecommendationProfile","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseRecommendationProfileResponse"}}}}}},"put":{"tags":["岗位推荐"],"operationId":"updateCompanyRecommendationProfile","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecommendationProfileRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseRecommendationProfileResponse"}}}}}}},
"/admin/job-recommendation-profiles": {"get":{"tags":["岗位推荐"],"operationId":"adminRecommendationProfiles","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponsePageRecommendationProfileResponse"}}}}}}}, "/admin/job-recommendation-profiles": {"get":{"tags":["岗位推荐"],"operationId":"adminRecommendationProfiles","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponsePageRecommendationProfileResponse"}}}}}}},
"/admin/job-recommendation-profiles/{jobId}/decision": {"post":{"tags":["岗位推荐"],"operationId":"decideRecommendationProfile","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecommendationDecisionRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseRecommendationProfileResponse"}}}}}}} "/admin/job-recommendation-profiles/{jobId}/decision": {"post":{"tags":["岗位推荐"],"operationId":"decideRecommendationProfile","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecommendationDecisionRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseRecommendationProfileResponse"}}}}}}},
"/companies/me/job-reviews": {"get":{"tags":["企业评价管理"],"operationId":"listCompanyJobReviews","parameters":[{"name":"jobId","in":"query","schema":{"type":"integer","format":"int64"}},{"name":"rating","in":"query","schema":{"type":"integer"}},{"name":"reviewModerationStatus","in":"query","schema":{"type":"string"}},{"name":"replyStatus","in":"query","schema":{"type":"string"}},{"name":"createdFrom","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"createdTo","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"page","in":"query","schema":{"type":"integer","default":0}},{"name":"size","in":"query","schema":{"type":"integer","default":20}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseReviewReplyPageResponse"}}}}}}},
"/companies/me/job-reviews/{reviewId}": {"get":{"tags":["企业评价管理"],"operationId":"getCompanyJobReview","parameters":[{"name":"reviewId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseCompanyReviewDetailResponse"}}}}}}},
"/companies/me/job-reviews/{reviewId}/reply": {"post":{"tags":["企业评价管理"],"operationId":"createCompanyReviewReply","parameters":[{"name":"reviewId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCompanyReviewReplyRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseCompanyReviewReplyResponse"}}}}}},"put":{"tags":["企业评价管理"],"operationId":"reviseCompanyReviewReply","parameters":[{"name":"reviewId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCompanyReviewReplyRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseCompanyReviewReplyResponse"}}}}}}},
"/admin/review-replies/pending": {"get":{"tags":["官方回复审核"],"operationId":"listPendingReviewReplies","parameters":[{"name":"companyId","in":"query","schema":{"type":"integer","format":"int64"}},{"name":"jobId","in":"query","schema":{"type":"integer","format":"int64"}},{"name":"riskLevel","in":"query","schema":{"type":"string"}},{"name":"createdFrom","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"createdTo","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"page","in":"query","schema":{"type":"integer","default":0}},{"name":"size","in":"query","schema":{"type":"integer","default":20}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseAdminReviewReplyPageResponse"}}}}}}},
"/admin/review-replies/{replyId}/decision": {"post":{"tags":["官方回复审核"],"operationId":"decideReviewReply","parameters":[{"name":"replyId","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewReplyDecisionRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseAdminReviewReplyResponse"}}}}}}}
}, },
"components": { "components": {
"schemas": { "schemas": {
@ -8070,6 +8075,21 @@
"ApiResponseRecommendationProfileResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RecommendationProfileResponse"}}}, "ApiResponseRecommendationProfileResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RecommendationProfileResponse"}}},
"ApiResponsePageJobRecommendationItem":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/JobRecommendationItem"}},"total":{"type":"integer"},"page":{"type":"integer"},"size":{"type":"integer"},"totalPages":{"type":"integer"}}}}}, "ApiResponsePageJobRecommendationItem":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/JobRecommendationItem"}},"total":{"type":"integer"},"page":{"type":"integer"},"size":{"type":"integer"},"totalPages":{"type":"integer"}}}}},
"ApiResponsePageRecommendationProfileResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RecommendationProfileResponse"}},"total":{"type":"integer"},"page":{"type":"integer"},"size":{"type":"integer"},"totalPages":{"type":"integer"}}}}}, "ApiResponsePageRecommendationProfileResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RecommendationProfileResponse"}},"total":{"type":"integer"},"page":{"type":"integer"},"size":{"type":"integer"},"totalPages":{"type":"integer"}}}}},
"CompanyReviewJobResponse": {"type":"object","properties":{"id":{"type":"integer","format":"int64"},"title":{"type":"string"}}},
"CompanyReviewReplyResponse": {"type":"object","properties":{"id":{"type":"integer","format":"int64"},"content":{"type":"string"},"status":{"type":"string"},"version":{"type":"integer","format":"int64"},"updatedAt":{"type":"string","format":"date-time"},"rejectionReason":{"type":"string","nullable":true}}},
"CompanyReviewItemResponse": {"type":"object","properties":{"reviewId":{"type":"integer","format":"int64"},"job":{"$ref":"#/components/schemas/CompanyReviewJobResponse"},"rating":{"type":"integer"},"content":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}},"dimensionScores":{"type":"object","additionalProperties":{"type":"integer"}},"reviewStatus":{"type":"string"},"reviewCreatedAt":{"type":"string","format":"date-time"},"reply":{"$ref":"#/components/schemas/CompanyReviewReplyResponse"}}},
"CompanyReviewDetailResponse":{"allOf":[{"$ref":"#/components/schemas/CompanyReviewItemResponse"}]},
"ReviewReplyPageResponse":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/CompanyReviewItemResponse"}},"total":{"type":"integer","format":"int64"},"page":{"type":"integer"},"size":{"type":"integer"},"totalPages":{"type":"integer"}}},
"CreateCompanyReviewReplyRequest":{"type":"object","required":["content","idempotencyKey"],"properties":{"content":{"type":"string","minLength":1,"maxLength":500},"idempotencyKey":{"type":"string","format":"uuid","maxLength":64}}},
"UpdateCompanyReviewReplyRequest":{"type":"object","required":["content","expectedVersion"],"properties":{"content":{"type":"string","minLength":1,"maxLength":500},"expectedVersion":{"type":"integer","format":"int64","minimum":0}}},
"ReviewReplyDecisionRequest":{"type":"object","required":["decision"],"properties":{"decision":{"type":"string","enum":["APPROVE","REJECT"]},"reason":{"type":"string","maxLength":500}}},
"AdminReviewReplyResponse":{"type":"object","properties":{"replyId":{"type":"integer","format":"int64"},"reviewId":{"type":"integer","format":"int64"},"jobId":{"type":"integer","format":"int64"},"jobTitle":{"type":"string"},"companyId":{"type":"integer","format":"int64"},"companyName":{"type":"string"},"rating":{"type":"integer"},"reviewContent":{"type":"string"},"reviewModerationStatus":{"type":"string"},"replyContent":{"type":"string"},"replyStatus":{"type":"string"},"version":{"type":"integer","format":"int64"},"riskScore":{"type":"number"},"riskLevel":{"type":"string"},"reasonCodes":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string","format":"date-time"}}},
"AdminReviewReplyPageResponse":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/AdminReviewReplyResponse"}},"total":{"type":"integer","format":"int64"},"page":{"type":"integer"},"size":{"type":"integer"},"totalPages":{"type":"integer"}}},
"ApiResponseReviewReplyPageResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/ReviewReplyPageResponse"},"requestId":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}}},
"ApiResponseCompanyReviewDetailResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/CompanyReviewDetailResponse"},"requestId":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}}},
"ApiResponseCompanyReviewReplyResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/CompanyReviewReplyResponse"},"requestId":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}}},
"ApiResponseAdminReviewReplyPageResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminReviewReplyPageResponse"},"requestId":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}}},
"ApiResponseAdminReviewReplyResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminReviewReplyResponse"},"requestId":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}}},
"AccountDeletionResponse": { "AccountDeletionResponse": {
"type": "object", "type": "object",
"properties": { "properties": {

357
docs/v40-company-review-management-design.md

@ -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 检查点的治理要求。

51
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) print('paths:', len(d['paths']))
assert len(d['paths']) == 154, f"Expected 154 paths, got {len(d['paths'])}" assert len(d['paths']) == 159, f"Expected 159 paths, got {len(d['paths'])}"
paths = d['paths'] paths = d['paths']
required = [ required = [
@ -306,10 +306,55 @@ for f in recommendation_sensitive:
assert False, f"schema {name} exposes forbidden field {f}" assert False, f"schema {name} exposes forbidden field {f}"
print(" V39 exactly 6 paths / 8 operations; recommendation schemas are redacted") print(" V39 exactly 6 paths / 8 operations; recommendation schemas are redacted")
# 统计 operation 总数并校验基线 190 # V40 企业评价官方回复:5 paths / 6 operations;企业、管理员和公开回复均为白名单载荷。
v40 = [
'/companies/me/job-reviews',
'/companies/me/job-reviews/{reviewId}',
'/companies/me/job-reviews/{reviewId}/reply',
'/admin/review-replies/pending',
'/admin/review-replies/{replyId}/decision',
]
assert all(k in paths for k in v40), "One or more V40 paths are missing"
assert len(v40) == 5, f"Expected exactly 5 V40 paths, got {len(v40)}"
assert sum(len(paths[k]) for k in v40) == 6, "Expected exactly 6 V40 operations"
for pt, method in [
('/companies/me/job-reviews', 'get'),
('/companies/me/job-reviews/{reviewId}', 'get'),
('/companies/me/job-reviews/{reviewId}/reply', 'post'),
('/companies/me/job-reviews/{reviewId}/reply', 'put'),
('/admin/review-replies/pending', 'get'),
('/admin/review-replies/{replyId}/decision', 'post'),
]:
assert method in paths[pt], f"Missing {method.upper()} {pt}"
for pt, method in [
('/companies/me/job-reviews/{reviewId}/reply', 'post'),
('/companies/me/job-reviews/{reviewId}/reply', 'put'),
('/admin/review-replies/{replyId}/decision', 'post'),
]:
assert paths[pt][method].get('requestBody', {}).get('content', {}).get('application/json', {}).get('schema', {}).get('$ref'), f"{method.upper()} {pt} requestBody schema missing"
for pt, method in [
('/companies/me/job-reviews', 'get'),
('/companies/me/job-reviews/{reviewId}', 'get'),
('/admin/review-replies/pending', 'get'),
('/companies/me/job-reviews/{reviewId}/reply', 'post'),
('/companies/me/job-reviews/{reviewId}/reply', 'put'),
('/admin/review-replies/{replyId}/decision', 'post'),
]:
response = paths[pt][method].get('responses', {}).get('200', {})
assert response.get('content', {}).get('application/json', {}).get('schema', {}).get('$ref'), f"{method.upper()} {pt} 200 schema missing"
v40_sensitive = ['studentUserId', 'phone', 'email', 'ipAddress', 'userAgent', 'moderationScore', 'reasonCodes']
for name in ('CompanyReviewItemResponse', 'CompanyReviewDetailResponse', 'CompanyReviewReplyResponse', 'ReviewReplyPageResponse'):
assert name in d['components']['schemas'], f"Missing V40 schema {name}"
text_schema = json.dumps(d['components']['schemas'][name])
for field in v40_sensitive:
assert field not in text_schema, f"V40 company schema {name} exposes {field}"
assert 'officialReply' not in json.dumps(d['components']['schemas']['CompanyReviewReplyResponse'])
print(" V40 exactly 5 paths / 6 operations; request/response schemas are present and redacted")
# 统计 operation 总数并校验 V40 基线 196
operations = sum(len(methods) for methods in d['paths'].values()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) print('operations:', operations)
assert operations == 190, f"Expected 190 operations, got {operations}" assert operations == 196, f"Expected 196 operations, got {operations}"
print(" Offer advance endpoints all present") print(" Offer advance endpoints all present")
print(" 26 operations all present") print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)") print(" Account deletion endpoints present (3 operations)")

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

@ -207,7 +207,16 @@ public enum ErrorCode {
RECOMMENDATION_FEEDBACK_CONCURRENT_UPDATE(10006, "recommendation feedback was modified concurrently"), RECOMMENDATION_FEEDBACK_CONCURRENT_UPDATE(10006, "recommendation feedback was modified concurrently"),
RECOMMENDATION_FEEDBACK_IDEMPOTENCY_CONFLICT(10007, "recommendation feedback idempotency conflict"), RECOMMENDATION_FEEDBACK_IDEMPOTENCY_CONFLICT(10007, "recommendation feedback idempotency conflict"),
RECOMMENDATION_FEEDBACK_NOT_FOUND(10008, "recommendation feedback not found"), RECOMMENDATION_FEEDBACK_NOT_FOUND(10008, "recommendation feedback not found"),
RECOMMENDATION_PROFILE_INVALID_REVIEW_STATE(10009, "invalid recommendation profile review state"); RECOMMENDATION_PROFILE_INVALID_REVIEW_STATE(10009, "invalid recommendation profile review state"),
// 企业评价官方回复 101xx
REVIEW_REPLY_NOT_FOUND(10101, "review reply not found"),
REVIEW_REPLY_ALREADY_EXISTS(10102, "review already has a reply"),
REVIEW_REPLY_INVALID_STATE(10103, "review reply is not in a valid state"),
REVIEW_REPLY_CONCURRENT_UPDATE(10104, "review reply was modified concurrently"),
REVIEW_REPLY_CONTENT_INVALID(10105, "invalid review reply content"),
REVIEW_REPLY_IDEMPOTENCY_CONFLICT(10106, "review reply idempotency conflict"),
REVIEW_REPLY_DECISION_INVALID(10107, "invalid review reply decision");
private final int code; private final int code;
private final String message; private final String message;

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

@ -76,7 +76,8 @@ public class GlobalExceptionHandler {
|| errorCode == ErrorCode.FEEDBACK_NOT_FOUND.getCode() || errorCode == ErrorCode.FEEDBACK_NOT_FOUND.getCode()
|| errorCode == ErrorCode.CONVERSATION_NOT_FOUND.getCode() || errorCode == ErrorCode.CONVERSATION_NOT_FOUND.getCode()
|| errorCode == ErrorCode.APPEAL_NOT_FOUND.getCode() || errorCode == ErrorCode.APPEAL_NOT_FOUND.getCode()
|| errorCode == ErrorCode.DATA_EXPORT_TASK_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; || errorCode == ErrorCode.DATA_EXPORT_TASK_NOT_FOUND.getCode()
|| errorCode == ErrorCode.REVIEW_REPLY_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND;
// 冲突 → 409 // 冲突 → 409
if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode() if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode()
|| errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode() || errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode()
@ -104,7 +105,11 @@ public class GlobalExceptionHandler {
// 面试冲突 → 409 // 面试冲突 → 409
|| errorCode == ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION.getCode() || errorCode == ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION.getCode()
|| errorCode == ErrorCode.INTERVIEW_COMPLETE_NOT_READY.getCode() || errorCode == ErrorCode.INTERVIEW_COMPLETE_NOT_READY.getCode()
|| errorCode == ErrorCode.INTERVIEW_ALREADY_RESPONDED.getCode()) || errorCode == ErrorCode.INTERVIEW_ALREADY_RESPONDED.getCode()
|| errorCode == ErrorCode.REVIEW_REPLY_ALREADY_EXISTS.getCode()
|| errorCode == ErrorCode.REVIEW_REPLY_INVALID_STATE.getCode()
|| errorCode == ErrorCode.REVIEW_REPLY_CONCURRENT_UPDATE.getCode()
|| errorCode == ErrorCode.REVIEW_REPLY_IDEMPOTENCY_CONFLICT.getCode())
return HttpStatus.CONFLICT; return HttpStatus.CONFLICT;
// 越权 → 403(新增:企业-求职者建会话需匹配) // 越权 → 403(新增:企业-求职者建会话需匹配)
if (errorCode == ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION.getCode()) if (errorCode == ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION.getCode())

78
mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyController.java

@ -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");
}
}

204
mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyService.java

@ -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();
}
}

102
mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementController.java

@ -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");
}
}

196
mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementService.java

@ -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();
}
}
}

90
mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReply.java

@ -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;
}

59
mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyHistory.java

@ -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();
}
}

19
mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyHistoryRepository.java

@ -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);
}

64
mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyRepository.java

@ -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);
}

237
mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyService.java

@ -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();
}
}

24
mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyWriter.java

@ -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);
}
}

32
mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java

@ -31,6 +31,38 @@ public interface JobReviewRepository extends JpaRepository<JobReview, Long> {
List<JobReview> findByJobIdAndStatusAndModerationStatusIn( List<JobReview> findByJobIdAndStatusAndModerationStatusIn(
Long jobId, String status, Collection<String> moderationStatuses); Long jobId, String status, Collection<String> moderationStatuses);
/** 企业评价管理:仅查询当前公司名下岗位的评价,回复状态通过左连接筛选。 */
@Query(value = "SELECT r.* FROM job_reviews r JOIN jobs j ON j.id = r.job_id "
+ "LEFT JOIN company_review_replies rr ON rr.review_id = r.id "
+ "WHERE j.company_id = :companyId "
+ "AND (:jobId IS NULL OR r.job_id = :jobId) "
+ "AND (:rating IS NULL OR r.rating = :rating) "
+ "AND (:reviewModerationStatus IS NULL OR r.moderation_status = :reviewModerationStatus) "
+ "AND (:replyStatus IS NULL OR (:replyStatus = 'NONE' AND rr.id IS NULL) "
+ " OR rr.moderation_status = :replyStatus) "
+ "AND (:createdFrom IS NULL OR r.created_at >= :createdFrom) "
+ "AND (:createdTo IS NULL OR r.created_at <= :createdTo) "
+ "ORDER BY r.created_at DESC",
countQuery = "SELECT COUNT(*) FROM job_reviews r JOIN jobs j ON j.id = r.job_id "
+ "LEFT JOIN company_review_replies rr ON rr.review_id = r.id "
+ "WHERE j.company_id = :companyId "
+ "AND (:jobId IS NULL OR r.job_id = :jobId) "
+ "AND (:rating IS NULL OR r.rating = :rating) "
+ "AND (:reviewModerationStatus IS NULL OR r.moderation_status = :reviewModerationStatus) "
+ "AND (:replyStatus IS NULL OR (:replyStatus = 'NONE' AND rr.id IS NULL) "
+ " OR rr.moderation_status = :replyStatus) "
+ "AND (:createdFrom IS NULL OR r.created_at >= :createdFrom) "
+ "AND (:createdTo IS NULL OR r.created_at <= :createdTo)",
nativeQuery = true)
Page<JobReview> findCompanyReviews(@Param("companyId") Long companyId,
@Param("jobId") Long jobId,
@Param("rating") Integer rating,
@Param("reviewModerationStatus") String reviewModerationStatus,
@Param("replyStatus") String replyStatus,
@Param("createdFrom") LocalDateTime createdFrom,
@Param("createdTo") LocalDateTime createdTo,
Pageable pageable);
// ── 防刷规则查询 ────────────────────────────────────────── // ── 防刷规则查询 ──────────────────────────────────────────
long countByStudentUserIdAndCreatedAtAfter(Long studentUserId, LocalDateTime after); long countByStudentUserIdAndCreatedAtAfter(Long studentUserId, LocalDateTime after);

36
mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java

@ -39,6 +39,8 @@ public class JobReviewService {
private final JobService jobService; private final JobService jobService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final AuditService auditService; private final AuditService auditService;
/** V40 官方回复只读投影;公开评价批量加载,避免逐条查询。 */
private final CompanyReviewReplyRepository companyReviewReplyRepository;
private static final ObjectMapper mapper = new ObjectMapper(); private static final ObjectMapper mapper = new ObjectMapper();
private static final int MAX_PAGE_SIZE = 50; private static final int MAX_PAGE_SIZE = 50;
private static final Set<String> ALLOWED_STATUSES = Set.of("PUBLISHED", "HIDDEN"); private static final Set<String> ALLOWED_STATUSES = Set.of("PUBLISHED", "HIDDEN");
@ -47,6 +49,8 @@ public class JobReviewService {
private static final int MAX_REQUEST_ID_LENGTH = 32; private static final int MAX_REQUEST_ID_LENGTH = 32;
private static final List<String> PUBLIC_MODERATION_STATUSES = List.of( private static final List<String> PUBLIC_MODERATION_STATUSES = List.of(
JobReview.MODERATION_AUTO_APPROVED, JobReview.MODERATION_APPROVED); JobReview.MODERATION_AUTO_APPROVED, JobReview.MODERATION_APPROVED);
private static final Set<String> PUBLIC_REPLY_STATUSES = Set.of(
CompanyReviewReply.AUTO_APPROVED, CompanyReviewReply.APPROVED);
// ── 角色校验 ────────────────────────────────────────────── // ── 角色校验 ──────────────────────────────────────────────
@ -147,7 +151,13 @@ public class JobReviewService {
jobId, "PUBLISHED", PUBLIC_MODERATION_STATUSES, PageRequest.of(page, size)); jobId, "PUBLISHED", PUBLIC_MODERATION_STATUSES, PageRequest.of(page, size));
List<Long> ids = result.getContent().stream().map(JobReview::getId).toList(); List<Long> ids = result.getContent().stream().map(JobReview::getId).toList();
Map<Long, Map<String, Integer>> dimsByReview = batchLoadDimensionMap(ids); Map<Long, Map<String, Integer>> dimsByReview = batchLoadDimensionMap(ids);
return result.map(r -> toView(r, dimsByReview.getOrDefault(r.getId(), Map.of()))); Map<Long, Map<String, Object>> repliesByReview = batchLoadPublicReplies(ids);
return result.map(r -> {
Map<String, Object> view = toView(r, dimsByReview.getOrDefault(r.getId(), Map.of()));
Map<String, Object> officialReply = repliesByReview.get(r.getId());
if (officialReply != null) view.put("officialReply", officialReply);
return view;
});
} }
/** 评价详情(公开;仅审核通过可公开) */ /** 评价详情(公开;仅审核通过可公开) */
@ -159,7 +169,10 @@ public class JobReviewService {
throw new BusinessException(ErrorCode.REVIEW_NOT_FOUND); throw new BusinessException(ErrorCode.REVIEW_NOT_FOUND);
if (!PUBLIC_MODERATION_STATUSES.contains(review.getModerationStatus())) if (!PUBLIC_MODERATION_STATUSES.contains(review.getModerationStatus()))
throw new BusinessException(ErrorCode.REVIEW_NOT_FOUND); // PENDING/REJECTED 不公开 throw new BusinessException(ErrorCode.REVIEW_NOT_FOUND); // PENDING/REJECTED 不公开
return toView(review, loadDimensionMap(reviewId)); Map<String, Object> view = toView(review, loadDimensionMap(reviewId));
Map<String, Object> officialReply = batchLoadPublicReplies(List.of(reviewId)).get(reviewId);
if (officialReply != null) view.put("officialReply", officialReply);
return view;
} }
// ── 修改 ────────────────────────────────────────────────── // ── 修改 ──────────────────────────────────────────────────
@ -427,6 +440,20 @@ public class JobReviewService {
return result; return result;
} }
/** 仅公开 AUTO_APPROVED/APPROVED 回复,且只返回正文与回复时间。 */
private Map<Long, Map<String, Object>> batchLoadPublicReplies(List<Long> reviewIds) {
Map<Long, Map<String, Object>> result = new HashMap<>();
if (reviewIds == null || reviewIds.isEmpty() || companyReviewReplyRepository == null) return result;
for (CompanyReviewReply reply : companyReviewReplyRepository.findByReviewIdIn(reviewIds)) {
if (!PUBLIC_REPLY_STATUSES.contains(reply.getModerationStatus())) continue;
Map<String, Object> view = new LinkedHashMap<>();
view.put("content", reply.getContent());
view.put("repliedAt", reply.getUpdatedAt() != null ? reply.getUpdatedAt() : reply.getCreatedAt());
result.put(reply.getReviewId(), view);
}
return result;
}
/** 八维汇总:始终返回全部 8 个维度;sampleCount<3 → INSUFFICIENT,>=3 → SUFFICIENT;无数据 → null */ /** 八维汇总:始终返回全部 8 个维度;sampleCount<3 → INSUFFICIENT,>=3 → SUFFICIENT;无数据 → null */
private Map<String, Object> buildDimensionSummary(List<Long> reviewIds) { private Map<String, Object> buildDimensionSummary(List<Long> reviewIds) {
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
@ -481,10 +508,11 @@ public class JobReviewService {
} }
public Map<String, Object> toView(JobReview r, Map<String, Integer> dimensionScores) { public Map<String, Object> toView(JobReview r, Map<String, Integer> dimensionScores) {
Map<String, Object> view = new HashMap<>(); Map<String, Object> view = new LinkedHashMap<>();
view.put("id", r.getId()); view.put("id", r.getId());
view.put("jobId", r.getJobId()); view.put("jobId", r.getJobId());
view.put("studentUserId", r.getStudentUserId()); // 公开/企业评价载荷不得暴露评价人内部主键。
view.put("reviewerLabel", "匿名求职者");
view.put("rating", r.getRating()); view.put("rating", r.getRating());
view.put("content", r.getContent()); view.put("content", r.getContent());
view.put("tags", fromJson(r.getTags())); view.put("tags", fromJson(r.getTags()));

28
mydreamjob-server/src/main/java/com/mydreamjob/review/ReviewModerationService.java

@ -108,6 +108,34 @@ public class ReviewModerationService {
new ArrayList<>(codes), BigDecimal.valueOf(capped), riskLevel(capped)); new ArrayList<>(codes), BigDecimal.valueOf(capped), riskLevel(capped));
} }
/**
* 企业官方回复预审。只使用与文本本身相关的规则,不使用学生评价的提交频率、
* 岗位重复评价或学生批量行为规则,避免把两种主体的风控语义混在一起。
*/
public ModerationResult moderateCompanyReply(String content) {
Set<String> codes = new LinkedHashSet<>();
double score = 0;
if (containsSensitiveWord(content)) {
codes.add(REASON_SENSITIVE_WORD);
score += 2;
}
if (containsContactInfo(content)) {
codes.add(REASON_CONTACT_INFO);
score += 2;
}
if (hasAnomalousPattern(content)) {
codes.add(REASON_PATTERN_ANOMALY);
score += 1;
}
if (codes.isEmpty()) {
return new ModerationResult(CompanyReviewReply.AUTO_APPROVED,
List.of(), BigDecimal.ZERO, null);
}
double capped = Math.min(score, 100);
return new ModerationResult(CompanyReviewReply.PENDING,
new ArrayList<>(codes), BigDecimal.valueOf(capped), riskLevel(capped));
}
// ── 规则实现 ────────────────────────────────────────────── // ── 规则实现 ──────────────────────────────────────────────
private boolean containsSensitiveWord(String content) { private boolean containsSensitiveWord(String content) {

20
mydreamjob-server/src/main/java/com/mydreamjob/review/dto/AdminReviewReplyPageResponse.java

@ -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;
}

33
mydreamjob-server/src/main/java/com/mydreamjob/review/dto/AdminReviewReplyResponse.java

@ -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;
}

8
mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewDetailResponse.java

@ -0,0 +1,8 @@
package com.mydreamjob.review.dto;
import lombok.Data;
/** 详情与列表使用同一白名单字段,避免 Entity 直接序列化。 */
@Data
public class CompanyReviewDetailResponse extends CompanyReviewItemResponse {
}

26
mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewItemResponse.java

@ -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;
}

13
mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewJobResponse.java

@ -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;
}

21
mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyResponse.java

@ -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;
}

16
mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CreateCompanyReviewReplyRequest.java

@ -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;
}

14
mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewReplyDecisionRequest.java

@ -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;
}

20
mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewReplyPageResponse.java

@ -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;
}

16
mydreamjob-server/src/main/java/com/mydreamjob/review/dto/UpdateCompanyReviewReplyRequest.java

@ -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;
}

51
mydreamjob-server/src/main/resources/db/migration/V38__company_review_replies.sql

@ -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;

111
mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewReplyServiceTest.java

@ -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());
}
}

62
mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementControllerTest.java

@ -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());
}
}

59
mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementServiceTest.java

@ -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());
}
}

228
mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyPersistenceIntegrationTest.java

@ -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;
}
}

151
mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyServiceTest.java

@ -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());
}
}

79
mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewPublicReplyTest.java

@ -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…
Cancel
Save