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