From 2698d064ae13d939085afed7e3680ce3669f5777 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Fri, 4 Sep 2026 15:56:35 +0800 Subject: [PATCH] feat: deliver V40 company review replies --- docs/backend-development-roadmap.md | 13 +- docs/company-review-management-api.md | 111 ++++++ docs/frontend-integration.md | 44 +++ docs/openapi-rc.json | 22 +- docs/v40-company-review-management-design.md | 357 ++++++++++++++++++ docs/verify_openapi.py | 51 ++- .../common/exception/ErrorCode.java | 11 +- .../exception/GlobalExceptionHandler.java | 9 +- .../review/AdminReviewReplyController.java | 78 ++++ .../review/AdminReviewReplyService.java | 204 ++++++++++ .../CompanyReviewManagementController.java | 102 +++++ .../CompanyReviewManagementService.java | 196 ++++++++++ .../mydreamjob/review/CompanyReviewReply.java | 90 +++++ .../review/CompanyReviewReplyHistory.java | 59 +++ .../CompanyReviewReplyHistoryRepository.java | 19 + .../review/CompanyReviewReplyRepository.java | 64 ++++ .../review/CompanyReviewReplyService.java | 237 ++++++++++++ .../review/CompanyReviewReplyWriter.java | 24 ++ .../review/JobReviewRepository.java | 32 ++ .../mydreamjob/review/JobReviewService.java | 36 +- .../review/ReviewModerationService.java | 28 ++ .../dto/AdminReviewReplyPageResponse.java | 20 + .../review/dto/AdminReviewReplyResponse.java | 33 ++ .../dto/CompanyReviewDetailResponse.java | 8 + .../review/dto/CompanyReviewItemResponse.java | 26 ++ .../review/dto/CompanyReviewJobResponse.java | 13 + .../dto/CompanyReviewReplyResponse.java | 21 ++ .../dto/CreateCompanyReviewReplyRequest.java | 16 + .../dto/ReviewReplyDecisionRequest.java | 14 + .../review/dto/ReviewReplyPageResponse.java | 20 + .../dto/UpdateCompanyReviewReplyRequest.java | 16 + .../migration/V38__company_review_replies.sql | 51 +++ .../review/AdminReviewReplyServiceTest.java | 111 ++++++ ...CompanyReviewManagementControllerTest.java | 62 +++ .../CompanyReviewManagementServiceTest.java | 59 +++ ...ReviewReplyPersistenceIntegrationTest.java | 228 +++++++++++ .../review/CompanyReviewReplyServiceTest.java | 151 ++++++++ .../review/JobReviewPublicReplyTest.java | 79 ++++ 38 files changed, 2698 insertions(+), 17 deletions(-) create mode 100644 docs/company-review-management-api.md create mode 100644 docs/v40-company-review-management-design.md create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyController.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyService.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementController.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementService.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReply.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyHistory.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyHistoryRepository.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyRepository.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyService.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyWriter.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/dto/AdminReviewReplyPageResponse.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/dto/AdminReviewReplyResponse.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewDetailResponse.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewItemResponse.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewJobResponse.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyResponse.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CreateCompanyReviewReplyRequest.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewReplyDecisionRequest.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewReplyPageResponse.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/review/dto/UpdateCompanyReviewReplyRequest.java create mode 100644 mydreamjob-server/src/main/resources/db/migration/V38__company_review_replies.sql create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewReplyServiceTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementControllerTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementServiceTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyPersistenceIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyServiceTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewPublicReplyTest.java diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 0fab9ac..95c96fa 100644 --- a/docs/backend-development-roadmap.md +++ b/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 | 公平性检测 | diff --git a/docs/company-review-management-api.md b/docs/company-review-management-api.md new file mode 100644 index 0000000..1a3d488 --- /dev/null +++ b/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 黑盒须单独执行并如实记录。 diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index a3db91e..d3b2614 100644 --- a/docs/frontend-integration.md +++ b/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` 的最新公开岗位,不展示伪造的测评匹配分。 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 5cd2467..4e20402 100644 --- a/docs/openapi-rc.json +++ b/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": { diff --git a/docs/v40-company-review-management-design.md b/docs/v40-company-review-management-design.md new file mode 100644 index 0000000..d4d8680 --- /dev/null +++ b/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 检查点的治理要求。 diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 0f522f2..65eb6fc 100644 --- a/docs/verify_openapi.py +++ b/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)") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index 34d573c..fefe2f7 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java @@ -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; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index 2f3a862..d95f071 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java @@ -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()) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyController.java new file mode 100644 index 0000000..6d6dd70 --- /dev/null +++ b/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> 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> 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyService.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewReplyService.java new file mode 100644 index 0000000..56e9b0e --- /dev/null +++ b/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 replies = replyRepository.findPending( + companyId, jobId, riskLevel, createdFrom, createdTo, PageRequest.of(page, size)); + List reviewIds = replies.getContent().stream().map(CompanyReviewReply::getReviewId).toList(); + Map reviews = reviewIds.isEmpty() ? Map.of() : reviewRepository.findAllById(reviewIds).stream() + .collect(Collectors.toMap(JobReview::getId, Function.identity(), (a, b) -> a)); + Set jobIds = reviews.values().stream().map(JobReview::getJobId) + .filter(java.util.Objects::nonNull).collect(Collectors.toSet()); + Map jobs = jobIds.isEmpty() ? Map.of() : jobRepository.findAllById(jobIds).stream() + .collect(Collectors.toMap(Job::getId, Function.identity(), (a, b) -> a)); + Set companyIds = jobs.values().stream().map(Job::getCompanyId) + .filter(java.util.Objects::nonNull).collect(Collectors.toSet()); + Map companies = companyIds.isEmpty() ? Map.of() : companyRepository.findAllById(companyIds).stream() + .collect(Collectors.toMap(Company::getId, Function.identity(), (a, b) -> a)); + List 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 reviews, + Map jobs, + Map 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 parseReasonCodes(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (IOException e) { + return List.of(); + } + } + + private String auditDetails(CompanyReviewReply reply, JobReview review, AuditContext context) { + Map 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(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementController.java new file mode 100644 index 0000000..e9299c1 --- /dev/null +++ b/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> 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> 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> 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> 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementService.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewManagementService.java new file mode 100644 index 0000000..839e590 --- /dev/null +++ b/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 REVIEW_STATUSES = Set.of( + JobReview.MODERATION_AUTO_APPROVED, JobReview.MODERATION_PENDING, + JobReview.MODERATION_APPROVED, JobReview.MODERATION_REJECTED); + private static final Set 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 reviewPage = reviewRepository.findCompanyReviews( + company.getId(), jobId, rating, reviewModerationStatus, replyStatus, + createdFrom, createdTo, PageRequest.of(page, size)); + List reviewIds = reviewPage.getContent().stream().map(JobReview::getId).toList(); + Map jobs = loadJobs(reviewPage.getContent()); + Map> dimensions = loadDimensions(reviewIds); + Map replies = loadReplies(reviewIds); + Map rejectionReasons = loadRejectionReasons(replies.values()); + + List 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 loadJobs(List reviews) { + Set 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> loadDimensions(List reviewIds) { + if (reviewIds.isEmpty()) return Map.of(); + Map> result = new HashMap<>(); + dimensionScoreRepository.findByReviewIdIn(reviewIds).forEach(d -> + result.computeIfAbsent(d.getReviewId(), ignored -> new HashMap<>()) + .put(d.getDimensionCode(), d.getScore())); + return result; + } + + private Map loadReplies(List reviewIds) { + if (reviewIds.isEmpty()) return Map.of(); + return replyRepository.findByReviewIdIn(reviewIds).stream() + .collect(Collectors.toMap(CompanyReviewReply::getReviewId, Function.identity())); + } + + private Map loadRejectionReasons(java.util.Collection replies) { + List ids = replies.stream().map(CompanyReviewReply::getId).filter(java.util.Objects::nonNull).toList(); + if (ids.isEmpty()) return Map.of(); + Map 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 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 parseTags(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (IOException e) { + return List.of(); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReply.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReply.java new file mode 100644 index 0000000..d4bbc94 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyHistory.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyHistory.java new file mode 100644 index 0000000..ad8d7bf --- /dev/null +++ b/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(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyHistoryRepository.java new file mode 100644 index 0000000..403b4c6 --- /dev/null +++ b/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 { + + List findByReplyIdIn(Collection replyIds); + + List findByReplyIdOrderByCreatedAtDesc(Long replyId); + + Optional findTopByReplyIdAndActionOrderByCreatedAtDesc( + Long replyId, String action); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyRepository.java new file mode 100644 index 0000000..7f16b51 --- /dev/null +++ b/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 { + + Optional findByReviewId(Long reviewId); + + Optional findByCompanyIdAndIdempotencyKey(Long companyId, String idempotencyKey); + + List findByReviewIdIn(Collection 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 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); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyService.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyService.java new file mode 100644 index 0000000..8441801 --- /dev/null +++ b/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 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 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(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyWriter.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyWriter.java new file mode 100644 index 0000000..70a48b4 --- /dev/null +++ b/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); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java index 671cdb3..2f8b347 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java @@ -31,6 +31,38 @@ public interface JobReviewRepository extends JpaRepository { List findByJobIdAndStatusAndModerationStatusIn( Long jobId, String status, Collection 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 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); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java index 1aabea3..ca3ab67 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java +++ b/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 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 PUBLIC_MODERATION_STATUSES = List.of( JobReview.MODERATION_AUTO_APPROVED, JobReview.MODERATION_APPROVED); + private static final Set 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 ids = result.getContent().stream().map(JobReview::getId).toList(); Map> dimsByReview = batchLoadDimensionMap(ids); - return result.map(r -> toView(r, dimsByReview.getOrDefault(r.getId(), Map.of()))); + Map> repliesByReview = batchLoadPublicReplies(ids); + return result.map(r -> { + Map view = toView(r, dimsByReview.getOrDefault(r.getId(), Map.of())); + Map 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 view = toView(review, loadDimensionMap(reviewId)); + Map 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> batchLoadPublicReplies(List reviewIds) { + Map> 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 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 buildDimensionSummary(List reviewIds) { Map result = new LinkedHashMap<>(); @@ -481,10 +508,11 @@ public class JobReviewService { } public Map toView(JobReview r, Map dimensionScores) { - Map view = new HashMap<>(); + Map 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())); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/ReviewModerationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/ReviewModerationService.java index d3c5d99..1e53950 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/ReviewModerationService.java +++ b/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 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) { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/AdminReviewReplyPageResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/AdminReviewReplyPageResponse.java new file mode 100644 index 0000000..ea082dc --- /dev/null +++ b/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 items; + private long total; + private int page; + private int size; + private int totalPages; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/AdminReviewReplyResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/AdminReviewReplyResponse.java new file mode 100644 index 0000000..39b575f --- /dev/null +++ b/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 reasonCodes; + private LocalDateTime createdAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewDetailResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewDetailResponse.java new file mode 100644 index 0000000..53d2fbe --- /dev/null +++ b/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 { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewItemResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewItemResponse.java new file mode 100644 index 0000000..1c1075c --- /dev/null +++ b/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 tags; + private Map dimensionScores; + private String reviewStatus; + private LocalDateTime reviewCreatedAt; + private CompanyReviewReplyResponse reply; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewJobResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewJobResponse.java new file mode 100644 index 0000000..3888152 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyResponse.java new file mode 100644 index 0000000..a7407b4 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CreateCompanyReviewReplyRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CreateCompanyReviewReplyRequest.java new file mode 100644 index 0000000..a60813f --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewReplyDecisionRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewReplyDecisionRequest.java new file mode 100644 index 0000000..10b51c1 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewReplyPageResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewReplyPageResponse.java new file mode 100644 index 0000000..9bc93ba --- /dev/null +++ b/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 items; + private long total; + private int page; + private int size; + private int totalPages; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/UpdateCompanyReviewReplyRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/UpdateCompanyReviewReplyRequest.java new file mode 100644 index 0000000..5f47fec --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/resources/db/migration/V38__company_review_replies.sql b/mydreamjob-server/src/main/resources/db/migration/V38__company_review_replies.sql new file mode 100644 index 0000000..240bd5e --- /dev/null +++ b/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; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewReplyServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewReplyServiceTest.java new file mode 100644 index 0000000..07599e6 --- /dev/null +++ b/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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementControllerTest.java new file mode 100644 index 0000000..e51c954 --- /dev/null +++ b/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 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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementServiceTest.java new file mode 100644 index 0000000..51f1784 --- /dev/null +++ b/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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyPersistenceIntegrationTest.java new file mode 100644 index 0000000..8632792 --- /dev/null +++ b/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 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 cleanupUsers = new ArrayList<>(); + private final List cleanupCompanies = new ArrayList<>(); + private final List cleanupJobs = new ArrayList<>(); + private final List cleanupReviews = new ArrayList<>(); + private final List cleanupReplies = new ArrayList<>(); + + @AfterEach + void cleanupFixtures() { + if (!cleanupUsers.isEmpty() || !cleanupReplies.isEmpty()) { + List 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 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 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; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyServiceTest.java new file mode 100644 index 0000000..145ddd0 --- /dev/null +++ b/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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewPublicReplyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewPublicReplyTest.java new file mode 100644 index 0000000..786d067 --- /dev/null +++ b/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 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 view = reviewService.listReviews(9L, 0, 20).getContent().get(0); + + assertFalse(view.containsKey("officialReply")); + } +}