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 检查点) |
| Flyway | **V1–V37 已应用**;V37 为岗位推荐分类、画像与学生反馈迁移(功能版本 V39,真实 MySQL/Docker 关键黑盒已通过) |
| 全量测试 | **1428/1428 通过**(V39 自动化回归,0 Failures / 0 Errors / 0 Skipped) |
| OpenAPI | **159 paths、196 operations**(V40 静态与运行时校验通过) |
| Flyway | **V1–V38 已成功执行**;V38 为企业评价官方回复迁移(功能版本 V40,真实 MySQL/Docker 已验收) |
| 全量测试 | 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、看板等核心闭环 |
| 当前结论 | 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 模块状态分类
@ -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 检查点)** |
| 已完成、待真实 MySQL/Docker 复验 | — |
| 已设计未编码 | 公平性检测、SHAP/规则解释、企业评价管理、企业人才推荐 |
| 真实黑盒已验收、待 Git 检查点 | **V40 企业评价管理与官方回复(Flyway V38,5 paths / 6 operations,PASS=62/FAIL=0/SKIP=0)** |
| 已设计未编码 | 公平性检测、SHAP/规则解释、企业人才推荐 |
| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 |
### 2.1 已确认的产品决策
@ -459,7 +460,7 @@ scoring_risk_scan_checkpoints
| P0/P1 | 同岗位竞争力排名 |
| P1 | 测评结果进入评分引擎、测评岗位推荐(V38/V39 已完成) |
| P1 | 统一求职进度(V34 已完成) |
| P1 | 企业评价管理 |
| P1 | ~~企业评价管理~~(V40 自动化、`clean verify` 与真实 MySQL/Docker 黑盒已通过,待 Git 检查点) |
| 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`)。
- 概览/趋势/分布由后端 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 测评驱动岗位推荐(编码中)
- 开关 `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"}}}},
"/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/{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": {
"schemas": {
@ -8070,6 +8075,21 @@
"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"}}}}},
"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": {
"type": "object",
"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'))
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']
required = [
@ -306,10 +306,55 @@ for f in recommendation_sensitive:
assert False, f"schema {name} exposes forbidden field {f}"
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())
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(" 26 operations all present")
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_IDEMPOTENCY_CONFLICT(10007, "recommendation feedback idempotency conflict"),
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 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.CONVERSATION_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
if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode()
|| errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode()
@ -104,7 +105,11 @@ public class GlobalExceptionHandler {
// 面试冲突 → 409
|| errorCode == ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION.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;
// 越权 → 403(新增:企业-求职者建会话需匹配)
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(
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);

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

@ -39,6 +39,8 @@ public class JobReviewService {
private final JobService jobService;
private final UserRepository userRepository;
private final AuditService auditService;
/** V40 官方回复只读投影;公开评价批量加载,避免逐条查询。 */
private final CompanyReviewReplyRepository companyReviewReplyRepository;
private static final ObjectMapper mapper = new ObjectMapper();
private static final int MAX_PAGE_SIZE = 50;
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 List<String> PUBLIC_MODERATION_STATUSES = List.of(
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));
List<Long> ids = result.getContent().stream().map(JobReview::getId).toList();
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);
if (!PUBLIC_MODERATION_STATUSES.contains(review.getModerationStatus()))
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;
}
/** 仅公开 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 */
private Map<String, Object> buildDimensionSummary(List<Long> reviewIds) {
Map<String, Object> result = new LinkedHashMap<>();
@ -481,10 +508,11 @@ public class JobReviewService {
}
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("jobId", r.getJobId());
view.put("studentUserId", r.getStudentUserId());
// 公开/企业评价载荷不得暴露评价人内部主键。
view.put("reviewerLabel", "匿名求职者");
view.put("rating", r.getRating());
view.put("content", r.getContent());
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));
}
/**
* 企业官方回复预审。只使用与文本本身相关的规则,不使用学生评价的提交频率、
* 岗位重复评价或学生批量行为规则,避免把两种主体的风控语义混在一起。
*/
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) {

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