Browse Source

企业回复与管理员审核回复相关功能代码

backup/master-local-20261012
pwklppg 2 months ago
parent
commit
2955305f02
  1. 134
      docs/review-reply-api.md
  2. 5
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  3. 3
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  4. 39
      mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java
  5. 60
      mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReply.java
  6. 90
      mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyController.java
  7. 42
      mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyRepository.java
  8. 330
      mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyService.java
  9. 15
      mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java
  10. 72
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/AdminCompanyReviewReplyResponse.java
  11. 13
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyCreateRequest.java
  12. 40
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyResponse.java
  13. 11
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyUpdateRequest.java
  14. 15
      mydreamjob-server/src/main/resources/db/migration/V34__company_review_replies.sql
  15. 401
      mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyServiceTest.java
  16. 3
      mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java
  17. 4
      mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java

134
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<AdminCompanyReviewReplyResponse>`:`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/*)。

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

@ -98,6 +98,11 @@ public enum ErrorCode {
// 评价权重 9106 // 评价权重 9106
REVIEW_WEIGHT_CONCURRENT_UPDATE(9106, "concurrent weight update conflict"), 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 // 面试 84xx
INTERVIEW_NOT_FOUND(8401, "interview not found"), INTERVIEW_NOT_FOUND(8401, "interview not found"),
INTERVIEW_ACCESS_DENIED(8402, "interview access denied"), INTERVIEW_ACCESS_DENIED(8402, "interview access denied"),

3
mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java

@ -102,6 +102,9 @@ public class SecurityConfig {
.requestMatchers("/jobs/*/favorite").authenticated() .requestMatchers("/jobs/*/favorite").authenticated()
.requestMatchers("/jobs/favorites").authenticated() .requestMatchers("/jobs/favorites").authenticated()
.requestMatchers("/jobs/*/reviews/**").authenticated() .requestMatchers("/jobs/*/reviews/**").authenticated()
// 企业回复评价(公开查询,写操作需登录)
.requestMatchers(HttpMethod.GET, "/reviews/*/reply").permitAll()
.requestMatchers("/reviews/*/reply").authenticated()
// 个性化评分(需登录,仅 STUDENT,Service 层校验) // 个性化评分(需登录,仅 STUDENT,Service 层校验)
.requestMatchers(HttpMethod.GET, "/jobs/*/personalized-review-score").authenticated() .requestMatchers(HttpMethod.GET, "/jobs/*/personalized-review-score").authenticated()
// 岗位公开查询 // 岗位公开查询

39
mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java

@ -1,8 +1,10 @@
package com.mydreamjob.review; package com.mydreamjob.review;
import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.review.dto.AdminCompanyReviewReplyResponse;
import com.mydreamjob.review.dto.ReviewDecisionRequest; import com.mydreamjob.review.dto.ReviewDecisionRequest;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; 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.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -30,6 +33,7 @@ import java.util.Map;
public class AdminReviewController { public class AdminReviewController {
private final JobReviewService reviewService; private final JobReviewService reviewService;
private final CompanyReviewReplyService replyService;
@Operation(summary = "待审评价列表", description = "仅 ADMIN;分页 + jobId/riskLevel/createdFrom/createdTo 筛选;不返回明文 IP/UA") @Operation(summary = "待审评价列表", description = "仅 ADMIN;分页 + jobId/riskLevel/createdFrom/createdTo 筛选;不返回明文 IP/UA")
@GetMapping("/pending") @GetMapping("/pending")
@ -85,7 +89,7 @@ public class AdminReviewController {
private Long resolveUserId(HttpServletRequest request) { private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken)
throw new BusinessException(ErrorCode.UNAUTHORIZED); throw new BusinessException(ErrorCode.UNAUTHORIZED);
return Long.valueOf(auth.getName()); return Long.valueOf(auth.getName());
} }
@ -93,4 +97,37 @@ public class AdminReviewController {
private String getRequestId(HttpServletRequest request) { private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id"); return request.getHeader("X-Request-Id");
} }
@Operation(summary = "待审企业回复列表", description = "仅 ADMIN;分页 + 企业/岗位/回复状态/创建时间范围筛选;moderationStatus 缺省为 PENDING")
@GetMapping("/replies/pending")
public ResponseEntity<ApiResponse<PageResponse<AdminCompanyReviewReplyResponse>>> 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<AdminCompanyReviewReplyResponse> 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<ApiResponse<com.mydreamjob.review.dto.CompanyReviewReplyResponse>> 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)));
}
} }

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

90
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<ApiResponse<CompanyReviewReplyResponse>> 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<ApiResponse<CompanyReviewReplyResponse>> 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<ApiResponse<CompanyReviewReplyResponse>> 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<ApiResponse<Map<String, Object>>> 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");
}
}

42
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<CompanyReviewReply, Long> {
Optional<CompanyReviewReply> findByReviewId(Long reviewId);
boolean existsByReviewId(Long reviewId);
List<CompanyReviewReply> findByReviewIdInAndModerationStatus(List<Long> reviewIds, String status);
List<CompanyReviewReply> findByReviewIdIn(List<Long> reviewIds);
Optional<CompanyReviewReply> 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<CompanyReviewReply> findAdminReplies(@Param("companyId") Long companyId,
@Param("jobId") Long jobId,
@Param("moderationStatus") String moderationStatus,
@Param("createdFrom") LocalDateTime createdFrom,
@Param("createdTo") LocalDateTime createdTo,
Pageable pageable);
}

330
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<String> 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<Long, CompanyReviewReplyResponse> batchGetApprovedReplies(List<Long> reviewIds) {
if (reviewIds == null || reviewIds.isEmpty()) {
return Map.of();
}
List<CompanyReviewReply> replies = replyRepository
.findByReviewIdInAndModerationStatus(reviewIds, CompanyReviewReply.MODERATION_APPROVED);
Map<Long, CompanyReviewReplyResponse> 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<AdminCompanyReviewReplyResponse> 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<AdminCompanyReviewReplyResponse> toAdminPage(Page<CompanyReviewReply> replies) {
if (replies.isEmpty()) {
return replies.map(this::toAdminView);
}
// 批量取评价、岗位、企业,避免 N+1
Set<Long> reviewIds = replies.stream()
.map(CompanyReviewReply::getReviewId).collect(Collectors.toSet());
Map<Long, JobReview> reviewMap = reviewRepository.findAllById(reviewIds).stream()
.collect(Collectors.toMap(JobReview::getId, r -> r));
Set<Long> jobIds = reviewMap.values().stream()
.map(JobReview::getJobId).collect(Collectors.toSet());
Map<Long, Job> jobMap = jobRepository.findAllById(jobIds).stream()
.collect(Collectors.toMap(Job::getId, j -> j));
Set<Long> companyIds = jobMap.values().stream()
.map(Job::getCompanyId).collect(Collectors.toSet());
Map<Long, Company> 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<Long, JobReview> reviewMap,
Map<Long, Job> jobMap,
Map<Long, Company> 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();
}
}

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

@ -39,6 +39,7 @@ public class JobReviewService {
private final JobService jobService; private final JobService jobService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final AuditService auditService; private final AuditService auditService;
private final CompanyReviewReplyRepository replyRepository;
private static final ObjectMapper mapper = new ObjectMapper(); private static final ObjectMapper mapper = new ObjectMapper();
private static final int MAX_PAGE_SIZE = 50; private static final int MAX_PAGE_SIZE = 50;
private static final Set<String> ALLOWED_STATUSES = Set.of("PUBLISHED", "HIDDEN"); private static final Set<String> ALLOWED_STATUSES = Set.of("PUBLISHED", "HIDDEN");
@ -493,6 +494,20 @@ public class JobReviewService {
view.put("createdAt", r.getCreatedAt()); view.put("createdAt", r.getCreatedAt());
view.put("updatedAt", r.getUpdatedAt()); view.put("updatedAt", r.getUpdatedAt());
view.put("dimensionScores", dimensionScores == null ? Map.of() : dimensionScores); 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; return view;
} }

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

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

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

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

15
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='企业对岗位评价的回复';

401
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<AdminCompanyReviewReplyResponse> 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<AdminCompanyReviewReplyResponse> 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;
}
}

3
mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java

@ -42,6 +42,7 @@ class JobReviewDimensionServiceTest {
@Mock private JobService jobService; @Mock private JobService jobService;
@Mock private UserRepository userRepository; @Mock private UserRepository userRepository;
@Mock private AuditService auditService; @Mock private AuditService auditService;
@Mock private CompanyReviewReplyRepository replyRepository;
@InjectMocks private JobReviewService reviewService; @InjectMocks private JobReviewService reviewService;
@ -55,6 +56,8 @@ class JobReviewDimensionServiceTest {
void setUp() { void setUp() {
lenient().when(dimensionScoreRepository.findByReviewId(any())).thenReturn(List.of()); lenient().when(dimensionScoreRepository.findByReviewId(any())).thenReturn(List.of());
lenient().when(dimensionScoreRepository.aggregateByDimension(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())) lenient().when(moderationService.moderate(any(), any(), any(), any(), any(), any(), any()))
.thenReturn(new ReviewModerationService.ModerationResult( .thenReturn(new ReviewModerationService.ModerationResult(
JobReview.MODERATION_AUTO_APPROVED, List.of(), JobReview.MODERATION_AUTO_APPROVED, List.of(),

4
mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java

@ -35,6 +35,7 @@ class JobReviewServiceTest {
@Mock private JobService jobService; @Mock private JobService jobService;
@Mock private UserRepository userRepository; @Mock private UserRepository userRepository;
@Mock private AuditService auditService; @Mock private AuditService auditService;
@Mock private CompanyReviewReplyRepository replyRepository;
@InjectMocks private JobReviewService reviewService; @InjectMocks private JobReviewService reviewService;
private static final Long STUDENT_ID = 100L; private static final Long STUDENT_ID = 100L;
@ -51,6 +52,9 @@ class JobReviewServiceTest {
// 维度相关默认空数据(旧用例不传 dimensionScores;lenient 避免部分用例未用到) // 维度相关默认空数据(旧用例不传 dimensionScores;lenient 避免部分用例未用到)
lenient().when(dimensionScoreRepository.findByReviewId(any())).thenReturn(List.of()); lenient().when(dimensionScoreRepository.findByReviewId(any())).thenReturn(List.of());
lenient().when(dimensionScoreRepository.aggregateByDimension(any())).thenReturn(List.of()); lenient().when(dimensionScoreRepository.aggregateByDimension(any())).thenReturn(List.of());
// 企业回复默认无(toView 会查询)
lenient().when(replyRepository.findByReviewIdAndModerationStatus(any(), any()))
.thenReturn(Optional.empty());
// 审核默认通过(AUTO_APPROVED);命中规则测试单独覆写 // 审核默认通过(AUTO_APPROVED);命中规则测试单独覆写
lenient().when(moderationService.moderate(any(), any(), any(), any(), any(), any(), any())) lenient().when(moderationService.moderate(any(), any(), any(), any(), any(), any(), any()))
.thenReturn(new ReviewModerationService.ModerationResult( .thenReturn(new ReviewModerationService.ModerationResult(

Loading…
Cancel
Save