diff --git a/docs/review-reply-api.md b/docs/review-reply-api.md new file mode 100644 index 0000000..39156cb --- /dev/null +++ b/docs/review-reply-api.md @@ -0,0 +1,134 @@ +# 企业回复评价功能接口说明 + +> 职来 (mydreamjob) · 企业回复评价模块 +> 基础路径:`/api/v1` · 企业侧回复接口需岗位所属企业;管理端接口仅 ADMIN + +## 1. 概述 + +在"学生评价岗位"基础上,新增**企业对岗位评价的官方回复**,并由管理员审核后对外展示: + +- 一条公开评价**只能回复一次**(`review_id` 唯一); +- 回复创建即 `moderationStatus = PENDING`,**不对外展示**,需管理员审核; +- 管理员 `APPROVED` → 公开可见;`REJECTED` → 永不公开; +- 公开查询只返回 `moderationStatus = APPROVED` 的回复; +- 回复所属企业可以随时查看自己回复的任意状态(审核中/已批准/被驳回),并在被驳回时知晓原因。 + +审核状态值:`PENDING`(待审) / `APPROVED`(已批准公开) / `REJECTED`(已驳回)。 + +## 2. 可见性规则 + +| 请求方 | PENDING | APPROVED | REJECTED | +|---|---|---|---| +| 回复所属企业(本人) | ✅ 可见 | ✅ 可见 | ✅ 可见(含 `moderationReason`) | +| 其他登录用户 / 匿名 | 视为不存在(9108) | ✅ 可见 | 视为不存在(9108) | + +> 匿名访问由 Spring Security 的匿名 token 表示,系统将其视为"非本人",只返回 `APPROVED`。 + +## 3. 企业侧回复接口 + +### 3.1 创建回复 `POST /reviews/{reviewId}/reply` + +请求体: +```json +{ "content": "感谢您的反馈,我们会持续改进。" } +``` + +- `content` 必填,≤500 字; +- 仅岗位所属企业可回复;评价不存在或未公开 → `REVIEW_NOT_FOUND`;非所属企业 → `FORBIDDEN`; +- 该评价已有回复 → `DUPLICATE_REPLY`(一条评价仅一条回复); +- 创建成功返回 `PENDING`,**不对外展示**。 + +### 3.2 查询回复 `GET /reviews/{reviewId}/reply` + +- 公开接口;按"可见性规则"返回; +- 本人可见任意状态;其他人/匿名仅见 `APPROVED`,否则 `REPLY_NOT_FOUND`。 + +### 3.3 更新回复 `PUT /reviews/{reviewId}/reply` + +请求体: +```json +{ "content": "感谢您的反馈,我们已优化面试流程。" } +``` + +- `content` ≤500 字; +- 仅回复所属企业可改(非所属 → `FORBIDDEN`);回复不存在 → `REPLY_NOT_FOUND`; +- 更新后 `moderationStatus` **重置为 PENDING**,需重新审核。 + +### 3.4 删除回复 `DELETE /reviews/{reviewId}/reply` + +- 仅回复所属企业;回复不存在 → `REPLY_NOT_FOUND`; +- 返回 `{ "message": "reply deleted" }`。 + +## 4. 管理端回复接口(仅 ADMIN) + +### 4.1 待审/回复列表 `GET /admin/reviews/replies/pending` + +- 仅 ADMIN(非 ADMIN → 403/`FORBIDDEN`,未登录 → 401/`UNAUTHORIZED`);分页; +- 可选筛选(均可不传,有默认):`companyId`(企业)、`jobId`(岗位)、`moderationStatus`(**默认 `PENDING`**)、`createdFrom`、`createdTo`、`page`(0)、`size`(20); +- `moderationStatus ∈ {PENDING, APPROVED, REJECTED}`,非法值 → `BAD_REQUEST`;`size` 超限(>100)或 `page<0` → `BAD_REQUEST`; +- 返回 `PageResponse`:`items[] / total / page / size / totalPages`; +- 每条含回复字段(`id, reviewId, companyUserId, content, moderationStatus, moderationReason, createdAt, updatedAt, moderationUpdatedAt`)+ 上下文补全(`jobId, jobTitle, companyId, companyName, reviewRating, reviewContent, studentUserId`); +- `moderationStatus` 缺省为 `PENDING`,要查所有/已批准/驳回,显式传该参数即可。 + +### 4.2 审核决策 `POST /admin/reviews/replies/{replyId}/decision` + +请求体: +```json +{ "decision": "APPROVED", "reason": "内容合规,同意公开" } +``` + +- `decision` 必填,`APPROVED` 或 `REJECTED`;`reason` 可选 ≤500 字(写入 `moderationReason`); +- 仅 ADMIN;仅 `PENDING` 可审,已审核重复提交 → `REPLY_ALREADY_DECIDED`; +- `APPROVED` → 公开可见;`REJECTED` → 永不公开;审核人来自 JWT; +- 返回审核后回复视图。 + +## 5. 权限一览 + +| 操作 | ADMIN | 岗位所属企业 | 其他企业/学生 | 未登录 | +|---|---|---|---|---| +| 创建/更新/删除回复 | — | ✅ | 403 | 401 | +| 查询回复(本人) | — | ✅ 任意状态 | — | — | +| 查询回复(公开) | ✅ (仅 APPROVED) | ✅ (仅 APPROVED) | ✅ (仅 APPROVED) | ✅ (仅 APPROVED) | +| 待审列表/审核决策 | ✅ | 403 | 403 | 401 | + +企业/审核者身份一律来自 JWT,不信任请求体 userId。 + +## 6. 数据库(V34) + +`V34__company_review_replies.sql` 新增表 `company_review_replies`: + +| 字段 | 说明 | +|---|---| +| `id` | 主键,自增 | +| `review_id` | 被回复的评价 id,**唯一**,一条评价一条回复 | +| `company_user_id` | 回复企业账号 id | +| `content` | 回复内容 | +| `moderation_status` | 默认 `PENDING`;`PENDING/APPROVED/REJECTED` | +| `moderation_reason` | 审核原因/意见 | +| `moderation_updated_at` | 审核时间 | +| `created_at` / `updated_at` | 创建/更新时间 | + +- 管理端筛选通过 `reply → review → job → company` 关联实现,不加额外字段; +- 不改既有表结构,历史迁移不动。 + +## 7. 错误码 + +| 码 | 场景 | +|---|---| +| 2001 | `UNAUTHORIZED` 未登录 | +| 2002 | `FORBIDDEN` 非 ADMIN / 非所属企业 | +| 1001 | `BAD_REQUEST` 参数非法(decision、分页越界、状态非法等) | +| 9102 | `REVIEW_NOT_FOUND` 评价不存在或未公开 | +| 9107 | `DUPLICATE_REPLY` 该评价已有回复 | +| 9108 | `REPLY_NOT_FOUND` 回复不存在/非本人查询非 APPROVED 回复 | +| 9109 | `REPLY_ALREADY_DECIDED` 该回复已审核过 | + +## 8. 测试 + +覆盖:创建回复 PENDING 且不公开、非所属企业 403、重复回复 9107、所有者可见任意状态、匿名/他人仅见 APPROVED、更新后重置 PENDING、删除后查询 9108、管理员列表默认 PENDING 且筛选正确(企业/岗位/状态/时间/分页/非法值)、决策仅 ADMIN、仅 PENDING 可审重复 9109、非 ADMIN 调管理接口 403、视图上下文补全。 + +## 9. 已知限制 + +1. 回复不可多次审核;如需调整,企业先更新内容(重置为 PENDING)再重新走审核。 +2. 一条评价仅一条企业回复(当前设计)。 +3. 管理端列表接口与既有评价审核接口共用 `/admin/reviews` 前缀,路径区分节点(/replies/*)。 \ No newline at end of file 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 726014d..dbd01f8 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 @@ -98,6 +98,11 @@ public enum ErrorCode { // 评价权重 9106 REVIEW_WEIGHT_CONCURRENT_UPDATE(9106, "concurrent weight update conflict"), + // 企业回复 91xx + DUPLICATE_REPLY(9107, "duplicate reply"), + REPLY_NOT_FOUND(9108, "reply not found"), + REPLY_ALREADY_DECIDED(9109, "reply already decided"), + // 面试 84xx INTERVIEW_NOT_FOUND(8401, "interview not found"), INTERVIEW_ACCESS_DENIED(8402, "interview access denied"), diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index f183bb5..fc11d84 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -102,6 +102,9 @@ public class SecurityConfig { .requestMatchers("/jobs/*/favorite").authenticated() .requestMatchers("/jobs/favorites").authenticated() .requestMatchers("/jobs/*/reviews/**").authenticated() + // 企业回复评价(公开查询,写操作需登录) + .requestMatchers(HttpMethod.GET, "/reviews/*/reply").permitAll() + .requestMatchers("/reviews/*/reply").authenticated() // 个性化评分(需登录,仅 STUDENT,Service 层校验) .requestMatchers(HttpMethod.GET, "/jobs/*/personalized-review-score").authenticated() // 岗位公开查询 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java index 2981663..407a25c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java @@ -1,8 +1,10 @@ package com.mydreamjob.review; import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.api.PageResponse; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.review.dto.AdminCompanyReviewReplyResponse; import com.mydreamjob.review.dto.ReviewDecisionRequest; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -13,6 +15,7 @@ import org.springframework.data.domain.Page; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; @@ -30,6 +33,7 @@ import java.util.Map; public class AdminReviewController { private final JobReviewService reviewService; + private final CompanyReviewReplyService replyService; @Operation(summary = "待审评价列表", description = "仅 ADMIN;分页 + jobId/riskLevel/createdFrom/createdTo 筛选;不返回明文 IP/UA") @GetMapping("/pending") @@ -85,7 +89,7 @@ public class AdminReviewController { private Long resolveUserId(HttpServletRequest request) { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) + if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) throw new BusinessException(ErrorCode.UNAUTHORIZED); return Long.valueOf(auth.getName()); } @@ -93,4 +97,37 @@ public class AdminReviewController { private String getRequestId(HttpServletRequest request) { return request.getHeader("X-Request-Id"); } + + @Operation(summary = "待审企业回复列表", description = "仅 ADMIN;分页 + 企业/岗位/回复状态/创建时间范围筛选;moderationStatus 缺省为 PENDING") + @GetMapping("/replies/pending") + public ResponseEntity>> pendingReplies( + @RequestParam(required = false) Long companyId, + @RequestParam(required = false) Long jobId, + @RequestParam(required = false) String moderationStatus, + @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(request); + Page result = replyService.listAdminReplies( + userId, companyId, jobId, moderationStatus, createdFrom, createdTo, page, size); + return ResponseEntity.ok(ApiResponse.ok(PageResponse.of( + result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()), + getRequestId(request))); + } + + @Operation(summary = "企业回复审核决策", description = "仅 ADMIN;仅 PENDING 可审,重复提交 409") + @PostMapping("/replies/{replyId}/decision") + public ResponseEntity> decideReply( + @PathVariable Long replyId, + @Valid @RequestBody ReviewDecisionRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + com.mydreamjob.review.dto.CompanyReviewReplyResponse response = replyService.moderateReply( + userId, replyId, req.getDecision(), req.getReason()); + return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); + } } 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..1df7a1a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReply.java @@ -0,0 +1,60 @@ +package com.mydreamjob.review; + +import jakarta.persistence.*; +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.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "company_review_replies", + uniqueConstraints = @UniqueConstraint(columnNames = "review_id")) +@EntityListeners(AuditingEntityListener.class) +public class CompanyReviewReply { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "review_id", nullable = false) + private Long reviewId; + + @Column(name = "company_user_id", nullable = false) + private Long companyUserId; + + @Column(nullable = false, columnDefinition = "text") + private String content; + + // ── 审核状态 ────────────────────────────────────────────── + + public static final String MODERATION_PENDING = "PENDING"; + public static final String MODERATION_APPROVED = "APPROVED"; + public static final String MODERATION_REJECTED = "REJECTED"; + + @Column(name = "moderation_status", nullable = false, length = 20) + @Builder.Default + private String moderationStatus = MODERATION_PENDING; + + @Column(name = "moderation_reason", length = 500) + private String moderationReason; + + @Column(name = "moderation_updated_at") + private LocalDateTime moderationUpdatedAt; + + @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/CompanyReviewReplyController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyController.java new file mode 100644 index 0000000..07194be --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyController.java @@ -0,0 +1,90 @@ +package com.mydreamjob.review; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.review.dto.CompanyReviewReplyCreateRequest; +import com.mydreamjob.review.dto.CompanyReviewReplyResponse; +import com.mydreamjob.review.dto.CompanyReviewReplyUpdateRequest; +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.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@Tag(name = "企业回复评价", description = "企业对岗位评价进行回复") +@RestController +@RequiredArgsConstructor +public class CompanyReviewReplyController { + + private final CompanyReviewReplyService replyService; + + @Operation(summary = "创建回复", description = "仅岗位所属企业;一条评价只能回复一次") + @PostMapping("/reviews/{reviewId}/reply") + public ResponseEntity> create( + @PathVariable Long reviewId, + @Valid @RequestBody CompanyReviewReplyCreateRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + CompanyReviewReplyResponse response = replyService.createReply(userId, reviewId, req); + return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); + } + + @Operation(summary = "查询回复", description = "公开访问;回复所属企业可见任意状态,其他用户仅见已审核通过的回复") + @GetMapping("/reviews/{reviewId}/reply") + public ResponseEntity> get( + @PathVariable Long reviewId, + HttpServletRequest request) { + Long userId = currentUserIdOrNull(); + CompanyReviewReplyResponse response = replyService.getReply(userId, reviewId); + return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); + } + + @Operation(summary = "更新回复", description = "仅回复所属企业;更新后需重新审核") + @PutMapping("/reviews/{reviewId}/reply") + public ResponseEntity> update( + @PathVariable Long reviewId, + @Valid @RequestBody CompanyReviewReplyUpdateRequest req, + HttpServletRequest request) { + Long userId = resolveUserId(request); + CompanyReviewReplyResponse response = replyService.updateReply(userId, reviewId, req); + return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); + } + + @Operation(summary = "删除回复", description = "仅回复所属企业") + @DeleteMapping("/reviews/{reviewId}/reply") + public ResponseEntity>> delete( + @PathVariable Long reviewId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + replyService.deleteReply(userId, reviewId); + return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "reply deleted"), getRequestId(request))); + } + + private Long resolveUserId(HttpServletRequest request) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { + throw new com.mydreamjob.common.exception.BusinessException( + com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED); + } + return Long.valueOf(auth.getName()); + } + + /** 返回当前登录用户 id;匿名访问时返回 null(用于公开查询接口的鉴权感知) */ + private Long currentUserIdOrNull() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { + return null; + } + return Long.valueOf(auth.getName()); + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} 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..4b64896 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyRepository.java @@ -0,0 +1,42 @@ +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.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface CompanyReviewReplyRepository extends JpaRepository { + + Optional findByReviewId(Long reviewId); + + boolean existsByReviewId(Long reviewId); + + List findByReviewIdInAndModerationStatus(List reviewIds, String status); + + List findByReviewIdIn(List reviewIds); + + Optional findByReviewIdAndModerationStatus(Long reviewId, String moderationStatus); + + /** 管理端企业回复列表:可 companyId/jobId/moderationStatus/时间范围 筛选,分页 */ + @Query("SELECT r FROM CompanyReviewReply r" + + " JOIN JobReview rv ON rv.id = r.reviewId" + + " WHERE (:companyId IS NULL OR rv.jobId IN" + + " (SELECT j.id FROM Job j WHERE j.companyId = :companyId))" + + " AND (:jobId IS NULL OR rv.jobId = :jobId)" + + " AND (:moderationStatus IS NULL OR r.moderationStatus = :moderationStatus)" + + " AND (:createdFrom IS NULL OR r.createdAt >= :createdFrom)" + + " AND (:createdTo IS NULL OR r.createdAt <= :createdTo)") + Page findAdminReplies(@Param("companyId") Long companyId, + @Param("jobId") Long jobId, + @Param("moderationStatus") String moderationStatus, + @Param("createdFrom") LocalDateTime createdFrom, + @Param("createdTo") LocalDateTime createdTo, + Pageable pageable); +} 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..f90272c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyService.java @@ -0,0 +1,330 @@ +package com.mydreamjob.review; + +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.job.JobService; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.review.dto.AdminCompanyReviewReplyResponse; +import com.mydreamjob.review.dto.CompanyReviewReplyCreateRequest; +import com.mydreamjob.review.dto.CompanyReviewReplyResponse; +import com.mydreamjob.review.dto.CompanyReviewReplyUpdateRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CompanyReviewReplyService { + + private static final int MAX_PAGE_SIZE = 100; + private static final Set ALLOWED_REPLY_STATUSES = + Set.of(CompanyReviewReply.MODERATION_PENDING, + CompanyReviewReply.MODERATION_APPROVED, + CompanyReviewReply.MODERATION_REJECTED); + + private final CompanyReviewReplyRepository replyRepository; + private final JobReviewRepository reviewRepository; + private final JobRepository jobRepository; + private final CompanyRepository companyRepository; + private final UserRepository userRepository; + private final JobService jobService; + + // ── 企业归属校验 ────────────────────────────────────────── + + /** + * 校验当前用户是否是该评价所属岗位的企业 + */ + private void assertCompanyOwner(Long userId, JobReview review) { + Job job = jobService.getJob(review.getJobId()); + Company company = companyRepository.findByOwnerUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN)); + if (!company.getId().equals(job.getCompanyId())) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } + + // ── 创建回复 ────────────────────────────────────────────── + + @Transactional + public CompanyReviewReplyResponse createReply(Long userId, Long reviewId, + CompanyReviewReplyCreateRequest req) { + // 1. 校验评价存在 + JobReview review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_NOT_FOUND)); + + // 2. 校验评价已公开(只有审核通过的评价才能被回复) + if (!JobReview.MODERATION_AUTO_APPROVED.equals(review.getModerationStatus()) + && !JobReview.MODERATION_APPROVED.equals(review.getModerationStatus())) { + throw new BusinessException(ErrorCode.REVIEW_NOT_FOUND); + } + + // 3. 校验企业归属 + assertCompanyOwner(userId, review); + + // 4. 校验是否已回复(一条评价只能回复一次) + if (replyRepository.existsByReviewId(reviewId)) { + throw new BusinessException(ErrorCode.DUPLICATE_REPLY); + } + + // 5. 创建回复(默认 PENDING 状态,需人工审核) + CompanyReviewReply reply = CompanyReviewReply.builder() + .reviewId(reviewId) + .companyUserId(userId) + .content(req.getContent()) + .moderationStatus(CompanyReviewReply.MODERATION_PENDING) + .build(); + + reply = replyRepository.save(reply); + log.info("企业回复创建成功:replyId={}, reviewId={}, companyId={}", + reply.getId(), reviewId, userId); + + return toResponse(reply); + } + + // ── 查询回复 ────────────────────────────────────────────── + + /** + * 查询单条回复(公开接口) + * 鉴权感知:回复所属企业可以看到自己回复的任何状态(审核中/已批准/被驳回); + * 匿名用户与其他用户仅能看到已审核通过(APPROVED)的回复。 + */ + public CompanyReviewReplyResponse getReply(Long userId, Long reviewId) { + CompanyReviewReply reply = replyRepository.findByReviewId(reviewId) + .orElseThrow(() -> new BusinessException(ErrorCode.REPLY_NOT_FOUND)); + + // 本人(回复所属企业)可看到任意状态 + boolean isOwner = userId != null && userId.equals(reply.getCompanyUserId()); + + // 非本人:只有审核通过的回复才对外展示,否则视为未找到 + if (!isOwner && !CompanyReviewReply.MODERATION_APPROVED.equals(reply.getModerationStatus())) { + throw new BusinessException(ErrorCode.REPLY_NOT_FOUND); + } + + return toResponse(reply); + } + + /** + * 批量查询回复(用于评价列表接口,仅返回审核通过的) + */ + public Map batchGetApprovedReplies(List reviewIds) { + if (reviewIds == null || reviewIds.isEmpty()) { + return Map.of(); + } + + List replies = replyRepository + .findByReviewIdInAndModerationStatus(reviewIds, CompanyReviewReply.MODERATION_APPROVED); + + Map result = new HashMap<>(); + for (CompanyReviewReply reply : replies) { + result.put(reply.getReviewId(), toResponse(reply)); + } + return result; + } + + // ── 更新回复(企业侧) ────────────────────────────────────── + + @Transactional + public CompanyReviewReplyResponse updateReply(Long userId, Long reviewId, + CompanyReviewReplyUpdateRequest req) { + // 1. 查找回复 + CompanyReviewReply reply = replyRepository.findByReviewId(reviewId) + .orElseThrow(() -> new BusinessException(ErrorCode.REPLY_NOT_FOUND)); + + // 2. 校验企业归属 + JobReview review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_NOT_FOUND)); + assertCompanyOwner(userId, review); + + // 3. 更新内容(审核状态重置为 PENDING,需重新审核) + if (req.getContent() != null && !req.getContent().isBlank()) { + reply.setContent(req.getContent()); + reply.setModerationStatus(CompanyReviewReply.MODERATION_PENDING); + reply.setModerationUpdatedAt(null); + } + + reply = replyRepository.save(reply); + log.info("企业回复更新:replyId={}, reviewId={}", reply.getId(), reviewId); + + return toResponse(reply); + } + + // ── 删除回复(企业侧) ────────────────────────────────────── + + @Transactional + public void deleteReply(Long userId, Long reviewId) { + // 1. 查找回复 + CompanyReviewReply reply = replyRepository.findByReviewId(reviewId) + .orElseThrow(() -> new BusinessException(ErrorCode.REPLY_NOT_FOUND)); + + // 2. 校验企业归属 + JobReview review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_NOT_FOUND)); + assertCompanyOwner(userId, review); + + // 3. 删除 + replyRepository.delete(reply); + log.info("企业回复删除:replyId={}, reviewId={}", reply.getId(), reviewId); + } + + // ── 管理员审核 ────────────────────────────────────────────── + + @Transactional + public CompanyReviewReplyResponse moderateReply(Long adminUserId, Long replyId, + String decision, String reason) { + // 0. 仅 ADMIN 可审核 + assertAdminRole(adminUserId); + // 1. 查找回复 + CompanyReviewReply reply = replyRepository.findById(replyId) + .orElseThrow(() -> new BusinessException(ErrorCode.REPLY_NOT_FOUND)); + + // 2. 只有 PENDING 状态可审核 + if (!CompanyReviewReply.MODERATION_PENDING.equals(reply.getModerationStatus())) { + throw new BusinessException(ErrorCode.REPLY_ALREADY_DECIDED); + } + + // 3. 更新审核状态 + boolean approved = CompanyReviewReply.MODERATION_APPROVED.equals(decision); + String finalStatus = approved ? CompanyReviewReply.MODERATION_APPROVED + : CompanyReviewReply.MODERATION_REJECTED; + reply.setModerationStatus(finalStatus); + reply.setModerationReason(reason); + reply.setModerationUpdatedAt(LocalDateTime.now()); + + reply = replyRepository.save(reply); + log.info("企业回复审核:replyId={}, decision={}, adminId={}", + replyId, finalStatus, adminUserId); + + return toResponse(reply); + } + + // ── 管理端回复列表(仅 ADMIN) ───────────────────────────── + // 关键关系:company_review_replies(reviewId) → job_reviews(jobId) → jobs(companyId) → companies + + /** + * 管理端企业回复列表(仅 ADMIN),支持 企业/岗位/回复状态/创建时间范围 筛选 + 分页。 + * moderationStatus 缺省视为 PENDING(待审)。 + */ + @Transactional(readOnly = true) + public Page listAdminReplies( + Long adminUserId, Long companyId, Long jobId, String moderationStatus, + LocalDateTime createdFrom, LocalDateTime createdTo, int page, int size) { + assertAdminRole(adminUserId); + assertValidPage(page, size); + String status = moderationStatus == null || moderationStatus.isBlank() + ? CompanyReviewReply.MODERATION_PENDING : moderationStatus; + assertValidReplyStatus(status); + + return toAdminPage(replyRepository.findAdminReplies( + companyId, jobId, status, createdFrom, createdTo, PageRequest.of(page, size))); + } + + private Page toAdminPage(Page replies) { + if (replies.isEmpty()) { + return replies.map(this::toAdminView); + } + + // 批量取评价、岗位、企业,避免 N+1 + Set reviewIds = replies.stream() + .map(CompanyReviewReply::getReviewId).collect(Collectors.toSet()); + Map reviewMap = reviewRepository.findAllById(reviewIds).stream() + .collect(Collectors.toMap(JobReview::getId, r -> r)); + + Set jobIds = reviewMap.values().stream() + .map(JobReview::getJobId).collect(Collectors.toSet()); + Map jobMap = jobRepository.findAllById(jobIds).stream() + .collect(Collectors.toMap(Job::getId, j -> j)); + + Set companyIds = jobMap.values().stream() + .map(Job::getCompanyId).collect(Collectors.toSet()); + Map companyMap = companyRepository.findAllById(companyIds).stream() + .collect(Collectors.toMap(Company::getId, c -> c)); + + return replies.map(r -> toAdminView(r, reviewMap, jobMap, companyMap)); + } + + private AdminCompanyReviewReplyResponse toAdminView(CompanyReviewReply reply) { + // 空页边界:仅组装回复自身 + return toAdminView(reply, Map.of(), Map.of(), Map.of()); + } + + private AdminCompanyReviewReplyResponse toAdminView(CompanyReviewReply reply, + Map reviewMap, + Map jobMap, + Map companyMap) { + AdminCompanyReviewReplyResponse.AdminCompanyReviewReplyResponseBuilder builder = + AdminCompanyReviewReplyResponse.builder() + .id(reply.getId()) + .reviewId(reply.getReviewId()) + .companyUserId(reply.getCompanyUserId()) + .content(reply.getContent()) + .moderationStatus(reply.getModerationStatus()) + .moderationReason(reply.getModerationReason()) + .createdAt(reply.getCreatedAt()) + .updatedAt(reply.getUpdatedAt()) + .moderationUpdatedAt(reply.getModerationUpdatedAt()); + + JobReview review = reviewMap.get(reply.getReviewId()); + if (review != null) { + builder.reviewRating(review.getRating()) + .reviewContent(review.getContent()) + .studentUserId(review.getStudentUserId()); + Job job = jobMap.get(review.getJobId()); + if (job != null) { + builder.jobId(job.getId()).jobTitle(job.getTitle()).companyId(job.getCompanyId()); + Company company = companyMap.get(job.getCompanyId()); + if (company != null) { + builder.companyName(company.getName()); + } + } + } + return builder.build(); + } + + private void assertAdminRole(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + boolean isAdmin = user.getRoles() != null + && user.getRoles().stream().anyMatch(r -> r == User.Role.ADMIN); + if (!isAdmin) throw new BusinessException(ErrorCode.FORBIDDEN); + } + + private void assertValidPage(int page, int size) { + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + private void assertValidReplyStatus(String status) { + if (!ALLOWED_REPLY_STATUSES.contains(status)) + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + // ── 视图转换 ────────────────────────────────────────────── + + private CompanyReviewReplyResponse toResponse(CompanyReviewReply reply) { + return CompanyReviewReplyResponse.builder() + .id(reply.getId()) + .reviewId(reply.getReviewId()) + .content(reply.getContent()) + .moderationStatus(reply.getModerationStatus()) + .moderationReason(reply.getModerationReason()) + .createdAt(reply.getCreatedAt()) + .updatedAt(reply.getUpdatedAt()) + .build(); + } +} 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..ef1d048 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,7 @@ public class JobReviewService { private final JobService jobService; private final UserRepository userRepository; private final AuditService auditService; + private final CompanyReviewReplyRepository replyRepository; 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"); @@ -493,6 +494,20 @@ public class JobReviewService { view.put("createdAt", r.getCreatedAt()); view.put("updatedAt", r.getUpdatedAt()); view.put("dimensionScores", dimensionScores == null ? Map.of() : dimensionScores); + + // 新增:查询企业回复(仅审核通过的) + CompanyReviewReply reply = replyRepository.findByReviewIdAndModerationStatus( + r.getId(), CompanyReviewReply.MODERATION_APPROVED).orElse(null); + if (reply != null) { + view.put("companyReply", Map.of( + "id", reply.getId(), + "content", reply.getContent(), + "createdAt", reply.getCreatedAt() + )); + } else { + view.put("companyReply", null); + } + return view; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/AdminCompanyReviewReplyResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/AdminCompanyReviewReplyResponse.java new file mode 100644 index 0000000..7f57cb5 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/AdminCompanyReviewReplyResponse.java @@ -0,0 +1,72 @@ +package com.mydreamjob.review.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 管理端企业回复视图:在回复字段基础上补全所涉及的岗位、企业与评价的上下文。 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "AdminCompanyReviewReplyResponse", description = "管理端企业回复(含岗位/企业/评价上下文)") +public class AdminCompanyReviewReplyResponse { + + @Schema(description = "回复 ID", example = "1") + private Long id; + + @Schema(description = "被回复的评价 ID", example = "100") + private Long reviewId; + + @Schema(description = "回复企业用户 id", example = "5") + private Long companyUserId; + + @Schema(description = "回复内容", example = "感谢您的反馈,我们会持续改进") + private String content; + + @Schema(description = "审核状态:PENDING/APPROVED/REJECTED", example = "PENDING") + private String moderationStatus; + + @Schema(description = "审核原因/意见", example = "内容合规") + private String moderationReason; + + @Schema(description = "创建时间", example = "2026-09-01T10:00:00") + private LocalDateTime createdAt; + + @Schema(description = "更新时间", example = "2026-09-01T10:00:00") + private LocalDateTime updatedAt; + + @Schema(description = "审核时间", example = "2026-09-01T10:00:00") + private LocalDateTime moderationUpdatedAt; + + // ── 上下文补全 ───────────────────────────────────────────── + + @Schema(description = "岗位 ID", example = "50") + private Long jobId; + + @Schema(description = "岗位标题", example = "Java 后端工程师") + private String jobTitle; + + @Schema(description = "企业 ID", example = "2") + private Long companyId; + + @Schema(description = "企业名称", example = "本源科技") + private String companyName; + + @Schema(description = "评价评分", example = "3") + private Integer reviewRating; + + @Schema(description = "评价内容", example = "整体体验不错") + private String reviewContent; + + @Schema(description = "评价学生用户 id", example = "2") + private Long studentUserId; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyCreateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyCreateRequest.java new file mode 100644 index 0000000..ab360a4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyCreateRequest.java @@ -0,0 +1,13 @@ +package com.mydreamjob.review.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class CompanyReviewReplyCreateRequest { + + @NotBlank(message = "content is required") + @Size(max = 500, message = "content must not exceed 500 characters") + private String content; +} 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..7677c73 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyResponse.java @@ -0,0 +1,40 @@ +package com.mydreamjob.review.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "CompanyReviewReplyResponse", description = "企业对评价的回复") +public class CompanyReviewReplyResponse { + + @Schema(description = "回复 ID", example = "1") + private Long id; + + @Schema(description = "被回复的评价 ID", example = "100") + private Long reviewId; + + @Schema(description = "回复内容", example = "感谢您的反馈,我们会持续改进") + private String content; + + @Schema(description = "审核状态:PENDING/APPROVED/REJECTED", example = "APPROVED") + private String moderationStatus; + + @Schema(description = "审核原因/意见", example = "内容合规") + private String moderationReason; + + @Schema(description = "创建时间", example = "2026-09-01T10:00:00") + private LocalDateTime createdAt; + + @Schema(description = "更新时间", example = "2026-09-01T10:00:00") + private LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyUpdateRequest.java new file mode 100644 index 0000000..296fb80 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyUpdateRequest.java @@ -0,0 +1,11 @@ +package com.mydreamjob.review.dto; + +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class CompanyReviewReplyUpdateRequest { + + @Size(max = 500, message = "content must not exceed 500 characters") + private String content; +} diff --git a/mydreamjob-server/src/main/resources/db/migration/V34__company_review_replies.sql b/mydreamjob-server/src/main/resources/db/migration/V34__company_review_replies.sql new file mode 100644 index 0000000..b0ebb64 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V34__company_review_replies.sql @@ -0,0 +1,15 @@ +CREATE TABLE company_review_replies ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + review_id BIGINT NOT NULL COMMENT '被回复的评价ID', + company_user_id BIGINT NOT NULL COMMENT '回复的企业用户ID', + content TEXT NOT NULL COMMENT '回复内容', + moderation_status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING/APPROVED/REJECTED', + moderation_reason VARCHAR(500) COMMENT '审核原因码(JSON数组)', + moderation_updated_at DATETIME COMMENT '审核更新时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uk_review_id (review_id), + KEY idx_company_user_id (company_user_id), + KEY idx_moderation_status (moderation_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='企业对岗位评价的回复'; 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..e6a4451 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyServiceTest.java @@ -0,0 +1,401 @@ +package com.mydreamjob.review; + +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.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.job.JobService; +import com.mydreamjob.review.dto.AdminCompanyReviewReplyResponse; +import com.mydreamjob.review.dto.CompanyReviewReplyCreateRequest; +import com.mydreamjob.review.dto.CompanyReviewReplyUpdateRequest; +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.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CompanyReviewReplyServiceTest { + + @Mock private CompanyReviewReplyRepository replyRepository; + @Mock private JobReviewRepository reviewRepository; + @Mock private JobRepository jobRepository; + @Mock private JobService jobService; + @Mock private CompanyRepository companyRepository; + @Mock private UserRepository userRepository; + @InjectMocks private CompanyReviewReplyService replyService; + + private static final Long COMPANY_USER_ID = 10L; // 企业账号 userId + private static final Long COMPANY_ID = 20L; // 企业 id + private static final Long OTHER_COMPANY_USER_ID = 11L; // 其他企业账号 userId + private static final Long OTHER_COMPANY_ID = 21L; // 其他企业 id + private static final Long REVIEW_ID = 100L; + private static final Long JOB_ID = 50L; + private static final Long ADMIN_USER_ID = 1L; + + @BeforeEach + void setUp() { + // 默认:adminId 为 ADMIN(lenient 避免未用到的用例报 UnnecessaryStubbing) + lenient().when(userRepository.findById(ADMIN_USER_ID)) + .thenReturn(Optional.of(adminUser())); + } + + private User adminUser() { + return User.builder().id(ADMIN_USER_ID).role(User.Role.ADMIN) + .roles(Set.of(User.Role.ADMIN)).build(); + } + + private User normalUser(Long id) { + return User.builder().id(id).role(User.Role.COMPANY) + .roles(Set.of(User.Role.COMPANY)).build(); + } + + private JobReview approvedReview() { + return JobReview.builder() + .id(REVIEW_ID).jobId(JOB_ID).studentUserId(999L) + .rating(4).content("Great job") + .moderationStatus(JobReview.MODERATION_AUTO_APPROVED) + .build(); + } + + private void mockCompanyOwnership(Long userId, Long companyId) { + when(companyRepository.findByOwnerUserId(userId)) + .thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(userId).build())); + } + + private void stubJobWithCompany(Long companyId) { + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).companyId(companyId).title("Java").build()); + } + + private CompanyReviewReplyCreateRequest buildCreateReq() { + CompanyReviewReplyCreateRequest req = new CompanyReviewReplyCreateRequest(); + req.setContent("感谢您的反馈,我们会持续改进"); + return req; + } + + // ── 创建(企业归属校验是核心)────────────────────────────── + + @Test void createReply_companyOwnsJob_shouldSucceed() { + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(approvedReview())); + stubJobWithCompany(COMPANY_ID); + mockCompanyOwnership(COMPANY_USER_ID, COMPANY_ID); + when(replyRepository.existsByReviewId(REVIEW_ID)).thenReturn(false); + when(replyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var r = replyService.createReply(COMPANY_USER_ID, REVIEW_ID, buildCreateReq()); + assertEquals(CompanyReviewReply.MODERATION_PENDING, r.getModerationStatus()); + assertEquals("感谢您的反馈,我们会持续改进", r.getContent()); + verify(replyRepository).save(any()); + } + + @Test void createReply_otherCompany_shouldReturn403() { + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(approvedReview())); + stubJobWithCompany(COMPANY_ID); + mockCompanyOwnership(OTHER_COMPANY_USER_ID, OTHER_COMPANY_ID); + + BusinessException ex = assertThrows(BusinessException.class, + () -> replyService.createReply(OTHER_COMPANY_USER_ID, REVIEW_ID, buildCreateReq())); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test void createReply_reviewNotApproved_shouldReturn404() { + JobReview pending = approvedReview(); + pending.setModerationStatus(JobReview.MODERATION_PENDING); + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(pending)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> replyService.createReply(COMPANY_USER_ID, REVIEW_ID, buildCreateReq())); + assertEquals(ErrorCode.REVIEW_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test void createReply_duplicate_shouldReturn409() { + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(approvedReview())); + stubJobWithCompany(COMPANY_ID); + mockCompanyOwnership(COMPANY_USER_ID, COMPANY_ID); + when(replyRepository.existsByReviewId(REVIEW_ID)).thenReturn(true); + + BusinessException ex = assertThrows(BusinessException.class, + () -> replyService.createReply(COMPANY_USER_ID, REVIEW_ID, buildCreateReq())); + assertEquals(ErrorCode.DUPLICATE_REPLY.getCode(), ex.getErrorCodeInt()); + } + + @Test void createReply_companyUserWithoutOwnedCompany_shouldReturn403() { + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(approvedReview())); + stubJobWithCompany(COMPANY_ID); + // companyRepository 不 stub -> 默认空 -> 无归属企业 + + BusinessException ex = assertThrows(BusinessException.class, + () -> replyService.createReply(COMPANY_USER_ID, REVIEW_ID, buildCreateReq())); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 查询 ───────────────────────────────────────────────── + + @Test void getReply_approved_shouldSucceed() { + CompanyReviewReply reply = CompanyReviewReply.builder() + .id(1L).reviewId(REVIEW_ID).companyUserId(COMPANY_USER_ID) + .content("回复内容").moderationStatus(CompanyReviewReply.MODERATION_APPROVED) + .createdAt(LocalDateTime.now()).build(); + when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); + + var r = replyService.getReply(null, REVIEW_ID); + assertEquals("回复内容", r.getContent()); + } + + @Test void getReply_pending_otherUser_shouldReturn404() { + CompanyReviewReply reply = CompanyReviewReply.builder() + .id(1L).reviewId(REVIEW_ID).content("回复内容") + .moderationStatus(CompanyReviewReply.MODERATION_PENDING).build(); + when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> replyService.getReply(999L, REVIEW_ID)); + assertEquals(ErrorCode.REPLY_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test void getReply_pending_anonymous_shouldReturn404() { + CompanyReviewReply reply = CompanyReviewReply.builder() + .id(1L).reviewId(REVIEW_ID).companyUserId(COMPANY_USER_ID).content("回复内容") + .moderationStatus(CompanyReviewReply.MODERATION_PENDING).build(); + when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); + + // 匿名请求:currentUserIdOrNull() 返回 null(原为把匿名 token 当 ADMIN 解析导致 500) + BusinessException ex = assertThrows(BusinessException.class, + () -> replyService.getReply(null, REVIEW_ID)); + assertEquals(ErrorCode.REPLY_NOT_FOUND.getCode(), ex.getErrorCodeInt()); + } + + @Test void getReply_pending_owner_shouldSucceedWithPending() { + CompanyReviewReply reply = CompanyReviewReply.builder() + .id(1L).reviewId(REVIEW_ID).companyUserId(COMPANY_USER_ID) + .content("回复内容").moderationStatus(CompanyReviewReply.MODERATION_PENDING).build(); + when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); + + var r = replyService.getReply(COMPANY_USER_ID, REVIEW_ID); + assertEquals(CompanyReviewReply.MODERATION_PENDING, r.getModerationStatus()); + } + + @Test void getReply_rejected_owner_shouldSucceedWithReason() { + CompanyReviewReply reply = CompanyReviewReply.builder() + .id(1L).reviewId(REVIEW_ID).companyUserId(COMPANY_USER_ID) + .content("回复内容").moderationStatus(CompanyReviewReply.MODERATION_REJECTED) + .moderationReason("内容违规").build(); + when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); + + var r = replyService.getReply(COMPANY_USER_ID, REVIEW_ID); + assertEquals(CompanyReviewReply.MODERATION_REJECTED, r.getModerationStatus()); + assertEquals("内容违规", r.getModerationReason()); + } + + @Test void batchGetApprovedReplies_shouldFilterApproved() { + CompanyReviewReply approved = CompanyReviewReply.builder() + .id(1L).reviewId(REVIEW_ID).content("OK") + .moderationStatus(CompanyReviewReply.MODERATION_APPROVED).build(); + CompanyReviewReply pending = CompanyReviewReply.builder() + .id(2L).reviewId(101L).content("P") + .moderationStatus(CompanyReviewReply.MODERATION_PENDING).build(); + // Service 使用 findByReviewIdInAndModerationStatus(ids, APPROVED),只返回 approved + when(replyRepository.findByReviewIdInAndModerationStatus(anyList(), eq(CompanyReviewReply.MODERATION_APPROVED))) + .thenReturn(java.util.List.of(approved)); + + var map = replyService.batchGetApprovedReplies(java.util.List.of(REVIEW_ID, 101L)); + assertEquals(1, map.size()); + assertTrue(map.containsKey(REVIEW_ID)); + assertFalse(map.containsKey(101L)); + } + + // ── 更新(企业侧)───────────────────────────────────────── + + @Test void updateReply_companyOwns_shouldResetToPending() { + CompanyReviewReply reply = CompanyReviewReply.builder() + .id(1L).reviewId(REVIEW_ID).companyUserId(COMPANY_USER_ID) + .content("旧内容").moderationStatus(CompanyReviewReply.MODERATION_APPROVED).build(); + when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(approvedReview())); + stubJobWithCompany(COMPANY_ID); + mockCompanyOwnership(COMPANY_USER_ID, COMPANY_ID); + when(replyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + CompanyReviewReplyUpdateRequest req = new CompanyReviewReplyUpdateRequest(); + req.setContent("新内容"); + var r = replyService.updateReply(COMPANY_USER_ID, REVIEW_ID, req); + assertEquals("新内容", r.getContent()); + assertEquals(CompanyReviewReply.MODERATION_PENDING, r.getModerationStatus()); + } + + @Test void updateReply_otherCompany_shouldReturn403() { + CompanyReviewReply reply = CompanyReviewReply.builder() + .id(1L).reviewId(REVIEW_ID).content("旧内容").build(); + when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(approvedReview())); + stubJobWithCompany(COMPANY_ID); + mockCompanyOwnership(OTHER_COMPANY_USER_ID, OTHER_COMPANY_ID); + + CompanyReviewReplyUpdateRequest req = new CompanyReviewReplyUpdateRequest(); + req.setContent("新内容"); + BusinessException ex = assertThrows(BusinessException.class, + () -> replyService.updateReply(OTHER_COMPANY_USER_ID, REVIEW_ID, req)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 删除(企业侧)───────────────────────────────────────── + + @Test void deleteReply_companyOwns_shouldSucceed() { + CompanyReviewReply reply = CompanyReviewReply.builder().id(1L).reviewId(REVIEW_ID).build(); + when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(approvedReview())); + stubJobWithCompany(COMPANY_ID); + mockCompanyOwnership(COMPANY_USER_ID, COMPANY_ID); + + assertDoesNotThrow(() -> replyService.deleteReply(COMPANY_USER_ID, REVIEW_ID)); + verify(replyRepository).delete(reply); + } + + @Test void deleteReply_otherCompany_shouldReturn403() { + CompanyReviewReply reply = CompanyReviewReply.builder().id(1L).reviewId(REVIEW_ID).build(); + when(replyRepository.findByReviewId(REVIEW_ID)).thenReturn(Optional.of(reply)); + when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(approvedReview())); + stubJobWithCompany(COMPANY_ID); + mockCompanyOwnership(OTHER_COMPANY_USER_ID, OTHER_COMPANY_ID); + + BusinessException ex = assertThrows(BusinessException.class, + () -> replyService.deleteReply(OTHER_COMPANY_USER_ID, REVIEW_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 管理员审核 ──────────────────────────────────────────── + + @Test void moderateReply_approve_shouldSucceed() { + CompanyReviewReply reply = CompanyReviewReply.builder() + .id(1L).reviewId(REVIEW_ID).content("内容") + .moderationStatus(CompanyReviewReply.MODERATION_PENDING).build(); + when(replyRepository.findById(1L)).thenReturn(Optional.of(reply)); + when(replyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var r = replyService.moderateReply(ADMIN_ROLE_ID(), 1L, CompanyReviewReply.MODERATION_APPROVED, "合规"); + assertEquals(CompanyReviewReply.MODERATION_APPROVED, r.getModerationStatus()); + } + + @Test void moderateReply_reject_shouldSucceed() { + CompanyReviewReply reply = CompanyReviewReply.builder() + .id(1L).reviewId(REVIEW_ID).content("内容") + .moderationStatus(CompanyReviewReply.MODERATION_PENDING).build(); + when(replyRepository.findById(1L)).thenReturn(Optional.of(reply)); + when(replyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + var r = replyService.moderateReply(ADMIN_ROLE_ID(), 1L, CompanyReviewReply.MODERATION_REJECTED, "违规"); + assertEquals(CompanyReviewReply.MODERATION_REJECTED, r.getModerationStatus()); + } + + @Test void moderateReply_alreadyDecided_shouldReturn409() { + CompanyReviewReply reply = CompanyReviewReply.builder() + .id(1L).reviewId(REVIEW_ID).content("内容") + .moderationStatus(CompanyReviewReply.MODERATION_APPROVED).build(); + when(replyRepository.findById(1L)).thenReturn(Optional.of(reply)); + + BusinessException ex = assertThrows(BusinessException.class, + () -> replyService.moderateReply(ADMIN_ROLE_ID(), 1L, CompanyReviewReply.MODERATION_APPROVED, "x")); + assertEquals(ErrorCode.REPLY_ALREADY_DECIDED.getCode(), ex.getErrorCodeInt()); + } + + @Test void moderateReply_nonAdmin_shouldReturnForbidden() { + Long nonAdminId = OTHER_COMPANY_USER_ID; + when(userRepository.findById(nonAdminId)).thenReturn(Optional.of(normalUser(nonAdminId))); + + BusinessException ex = assertThrows(BusinessException.class, + () -> replyService.moderateReply(nonAdminId, 1L, CompanyReviewReply.MODERATION_APPROVED, "x")); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + // ── 管理端回复列表(仅 ADMIN) ────────────────────────────── + + @Test void listAdminReplies_nonAdmin_shouldReturnForbidden() { + Long nonAdminId = OTHER_COMPANY_USER_ID; + when(userRepository.findById(nonAdminId)).thenReturn(Optional.of(normalUser(nonAdminId))); + + BusinessException ex = assertThrows(BusinessException.class, + () -> replyService.listAdminReplies(nonAdminId, null, null, null, null, null, 0, 20)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + + @Test void listAdminReplies_invalidStatus_shouldReturnBadRequest() { + BusinessException ex = assertThrows(BusinessException.class, + () -> replyService.listAdminReplies(ADMIN_USER_ID, null, null, "UNKNOWN", null, null, 0, 20)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test void listAdminReplies_invalidPage_shouldReturnBadRequest() { + assertThrows(BusinessException.class, + () -> replyService.listAdminReplies(ADMIN_USER_ID, null, null, null, null, null, -1, 20)); + assertThrows(BusinessException.class, + () -> replyService.listAdminReplies(ADMIN_USER_ID, null, null, null, null, null, 0, 0)); + assertThrows(BusinessException.class, + () -> replyService.listAdminReplies(ADMIN_USER_ID, null, null, null, null, null, 0, 101)); + } + + @Test void listAdminReplies_defaultStatusPending_andDelegatesFilters() { + CompanyReviewReply reply = CompanyReviewReply.builder() + .id(3L).reviewId(REVIEW_ID).companyUserId(COMPANY_USER_ID) + .content("回复").moderationStatus(CompanyReviewReply.MODERATION_PENDING).build(); + when(replyRepository.findAdminReplies(any(), any(), any(), any(), any(), any())) + .thenReturn(new PageImpl<>(List.of(reply))); + when(reviewRepository.findAllById(anyCollection())).thenReturn(List.of()); + + Page result = + replyService.listAdminReplies(ADMIN_USER_ID, COMPANY_ID, JOB_ID, null, null, null, 0, 20); + + assertEquals(1, result.getTotalElements()); + assertEquals(CompanyReviewReply.MODERATION_PENDING, result.getContent().get(0).getModerationStatus()); + verify(replyRepository).findAdminReplies(eq(COMPANY_ID), eq(JOB_ID), + eq(CompanyReviewReply.MODERATION_PENDING), any(), any(), any()); + } + + @Test void listAdminReplies_enrichesJobAndCompany() { + CompanyReviewReply reply = CompanyReviewReply.builder() + .id(3L).reviewId(REVIEW_ID).companyUserId(COMPANY_USER_ID) + .content("回复").moderationStatus(CompanyReviewReply.MODERATION_PENDING).build(); + JobReview review = JobReview.builder().id(REVIEW_ID).jobId(JOB_ID) + .studentUserId(2L).rating(3).content("评价内容").build(); + Job job = Job.builder().id(JOB_ID).companyId(COMPANY_ID).title("Java 后端工程师").build(); + Company company = Company.builder().id(COMPANY_ID).name("本源科技").build(); + + when(replyRepository.findAdminReplies(any(), any(), any(), any(), any(), any())) + .thenReturn(new PageImpl<>(List.of(reply))); + when(reviewRepository.findAllById(anyCollection())).thenReturn(List.of(review)); + when(jobRepository.findAllById(anyCollection())).thenReturn(List.of(job)); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of(company)); + + Page result = + replyService.listAdminReplies(ADMIN_USER_ID, null, null, null, null, null, 0, 20); + + AdminCompanyReviewReplyResponse v = result.getContent().get(0); + assertEquals("Java 后端工程师", v.getJobTitle()); + assertEquals("本源科技", v.getCompanyName()); + assertEquals(3, v.getReviewRating()); + assertEquals(2L, v.getStudentUserId()); + assertEquals(COMPANY_USER_ID, v.getCompanyUserId()); + } + + private Long ADMIN_ROLE_ID() { + return 1L; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java index e296a47..98d770b 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java @@ -42,6 +42,7 @@ class JobReviewDimensionServiceTest { @Mock private JobService jobService; @Mock private UserRepository userRepository; @Mock private AuditService auditService; + @Mock private CompanyReviewReplyRepository replyRepository; @InjectMocks private JobReviewService reviewService; @@ -55,6 +56,8 @@ class JobReviewDimensionServiceTest { void setUp() { lenient().when(dimensionScoreRepository.findByReviewId(any())).thenReturn(List.of()); lenient().when(dimensionScoreRepository.aggregateByDimension(any())).thenReturn(List.of()); + lenient().when(replyRepository.findByReviewIdAndModerationStatus(any(), any())) + .thenReturn(Optional.empty()); lenient().when(moderationService.moderate(any(), any(), any(), any(), any(), any(), any())) .thenReturn(new ReviewModerationService.ModerationResult( JobReview.MODERATION_AUTO_APPROVED, List.of(), diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java index f6b5ca0..161aa47 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java @@ -35,6 +35,7 @@ class JobReviewServiceTest { @Mock private JobService jobService; @Mock private UserRepository userRepository; @Mock private AuditService auditService; + @Mock private CompanyReviewReplyRepository replyRepository; @InjectMocks private JobReviewService reviewService; private static final Long STUDENT_ID = 100L; @@ -51,6 +52,9 @@ class JobReviewServiceTest { // 维度相关默认空数据(旧用例不传 dimensionScores;lenient 避免部分用例未用到) lenient().when(dimensionScoreRepository.findByReviewId(any())).thenReturn(List.of()); lenient().when(dimensionScoreRepository.aggregateByDimension(any())).thenReturn(List.of()); + // 企业回复默认无(toView 会查询) + lenient().when(replyRepository.findByReviewIdAndModerationStatus(any(), any())) + .thenReturn(Optional.empty()); // 审核默认通过(AUTO_APPROVED);命中规则测试单独覆写 lenient().when(moderationService.moderate(any(), any(), any(), any(), any(), any(), any())) .thenReturn(new ReviewModerationService.ModerationResult(