diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewController.java index 63bf36b..3c319fc 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewController.java @@ -11,11 +11,13 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; 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.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -40,14 +42,21 @@ public class JobReviewController { return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); } - @Operation(summary = "查询岗位评价列表", description = "公开访问;仅返回 PUBLISHED 评价,分页;每条含八维评分") + @Operation(summary = "查询岗位评价列表", description = "公开访问;仅返回 PUBLISHED 评价,分页;每条含八维评分;支持评分档/时间区间/未回复筛选") @GetMapping("/jobs/{jobId}/reviews") public ResponseEntity>> list( @PathVariable Long jobId, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String ratingType, + @RequestParam(defaultValue = "false") boolean unreplied, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime createdFrom, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime createdTo, HttpServletRequest request) { - Page> result = reviewService.listReviews(jobId, page, size); + Page> result = reviewService.listReviews( + jobId, ratingType, createdFrom, createdTo, unreplied, page, size); Map data = new HashMap<>(); data.put("items", result.getContent()); data.put("total", result.getTotalElements()); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java index 671cdb3..7766da1 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java @@ -41,6 +41,29 @@ public interface JobReviewRepository extends JpaRepository { long countByUaHashAndCreatedAtAfter(String uaHash, LocalDateTime after); + /** 公开列表(含筛选):PUBLISHED 且审核通过;支持评分档/时间范围/未回复筛选 */ + @Query("SELECT r FROM JobReview r" + + " WHERE r.jobId = :jobId AND r.status = :status" + + " AND r.moderationStatus IN :moderationStatuses" + + " AND (:ratingType IS NULL OR" + + " (:ratingType = 'GOOD' AND r.rating = 5)" + + " OR (:ratingType = 'MEDIUM' AND r.rating IN (3,4))" + + " OR (:ratingType = 'BAD' AND r.rating IN (1,2)))" + + " AND (:createdFrom IS NULL OR r.createdAt >= :createdFrom)" + + " AND (:createdTo IS NULL OR r.createdAt <= :createdTo)" + + " AND (:unreplied = false OR NOT EXISTS" + + " (SELECT 1 FROM CompanyReviewReply rr" + + " WHERE rr.reviewId = r.id AND rr.moderationStatus = 'APPROVED'))" + + " ORDER BY r.createdAt DESC") + Page findPublicReviews(@Param("jobId") Long jobId, + @Param("status") String status, + @Param("moderationStatuses") Collection moderationStatuses, + @Param("ratingType") String ratingType, + @Param("createdFrom") LocalDateTime createdFrom, + @Param("createdTo") LocalDateTime createdTo, + @Param("unreplied") boolean unreplied, + Pageable pageable); + /** 管理端待审列表:PENDING,支持 jobId / riskLevel / 时间范围筛选 */ @Query("SELECT r FROM JobReview r WHERE r.status = 'PUBLISHED' AND r.moderationStatus = 'PENDING'" + " AND (:jobId IS NULL OR r.jobId = :jobId)" 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 ef1d048..55a8d79 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java @@ -43,6 +43,7 @@ public class JobReviewService { 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"); + private static final Set ALLOWED_RATING_TYPES = Set.of("GOOD", "MEDIUM", "BAD"); private static final int MIN_SCORE = 1; private static final int MAX_SCORE = 5; private static final int MAX_REQUEST_ID_LENGTH = 32; @@ -67,6 +68,16 @@ public class JobReviewService { throw new BusinessException(ErrorCode.BAD_REQUEST); } + private void assertValidRatingType(String ratingType) { + if (ratingType != null && !ALLOWED_RATING_TYPES.contains(ratingType)) + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + private void assertValidTimeRange(LocalDateTime from, LocalDateTime to) { + if (from != null && to != null && from.isAfter(to)) + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + private void assertValidStatus(String status) { if (status != null && !ALLOWED_STATUSES.contains(status)) throw new BusinessException(ErrorCode.BAD_REQUEST); @@ -141,11 +152,20 @@ public class JobReviewService { // ── 查询 ────────────────────────────────────────────────── public Page> listReviews(Long jobId, int page, int size) { + return listReviews(jobId, null, null, null, false, page, size); + } + + /** 公开评价列表(含筛选):评分档/创建时间区间/未回复;均可不传 */ + public Page> listReviews(Long jobId, String ratingType, + LocalDateTime createdFrom, LocalDateTime createdTo, + boolean unreplied, int page, int size) { assertJobExists(jobId); assertValidPage(page, size); - Page result = reviewRepository - .findByJobIdAndStatusAndModerationStatusInOrderByCreatedAtDesc( - jobId, "PUBLISHED", PUBLIC_MODERATION_STATUSES, PageRequest.of(page, size)); + assertValidRatingType(ratingType); + assertValidTimeRange(createdFrom, createdTo); + Page result = reviewRepository.findPublicReviews( + jobId, "PUBLISHED", PUBLIC_MODERATION_STATUSES, + ratingType, createdFrom, createdTo, unreplied, PageRequest.of(page, size)); List ids = result.getContent().stream().map(JobReview::getId).toList(); Map> dimsByReview = batchLoadDimensionMap(ids); return result.map(r -> toView(r, dimsByReview.getOrDefault(r.getId(), Map.of()))); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java index 8559a8e..6f2b786 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java @@ -66,7 +66,7 @@ class JobReviewControllerTest { } @Test void listReviews_public_shouldReturn200() throws Exception { - when(reviewService.listReviews(eq(JOB_ID), eq(0), eq(20))) + when(reviewService.listReviews(eq(JOB_ID), any(), any(), any(), anyBoolean(), eq(0), eq(20))) .thenReturn(new PageImpl<>(List.of(Map.of("rating", 4)))); mockMvc.perform(get("/jobs/" + JOB_ID + "/reviews")) .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)); 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 161aa47..893c3dd 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java @@ -18,11 +18,13 @@ 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.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -51,6 +53,7 @@ class JobReviewServiceTest { .rating(4).content("不错的岗位").status("PUBLISHED").build(); // 维度相关默认空数据(旧用例不传 dimensionScores;lenient 避免部分用例未用到) lenient().when(dimensionScoreRepository.findByReviewId(any())).thenReturn(List.of()); + lenient().when(dimensionScoreRepository.findByReviewIdIn(any())).thenReturn(List.of()); lenient().when(dimensionScoreRepository.aggregateByDimension(any())).thenReturn(List.of()); // 企业回复默认无(toView 会查询) lenient().when(replyRepository.findByReviewIdAndModerationStatus(any(), any())) @@ -145,12 +148,64 @@ class JobReviewServiceTest { @Test void listReviews_shouldReturnPublishedOnly() { when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); - when(reviewRepository.findByJobIdAndStatusAndModerationStatusInOrderByCreatedAtDesc(eq(JOB_ID), eq("PUBLISHED"), any(), any())) + when(reviewRepository.findPublicReviews(eq(JOB_ID), eq("PUBLISHED"), any(), + isNull(), isNull(), isNull(), eq(false), any())) .thenReturn(new PageImpl<>(List.of(review))); var r = reviewService.listReviews(JOB_ID, 0, 20); assertEquals(1, r.getTotalElements()); } + // ── 列表筛选(好评/中评/差评/未回复/时间区间) ──────────── + + @Test void listReviews_withFilters_shouldPassToRepository() { + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + LocalDateTime from = LocalDateTime.of(2026, 1, 1, 0, 0); + LocalDateTime to = LocalDateTime.of(2026, 6, 1, 0, 0); + when(reviewRepository.findPublicReviews(eq(JOB_ID), eq("PUBLISHED"), any(), + eq("GOOD"), eq(from), eq(to), eq(true), any())) + .thenReturn(new PageImpl<>(List.of(review))); + var r = reviewService.listReviews(JOB_ID, "GOOD", from, to, true, 0, 20); + assertEquals(1, r.getTotalElements()); + verify(reviewRepository).findPublicReviews(eq(JOB_ID), eq("PUBLISHED"), any(), + eq("GOOD"), eq(from), eq(to), eq(true), any()); + } + + @Test void listReviews_invalidRatingType_shouldReturn400() { + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.listReviews(JOB_ID, "EXCELLENT", + LocalDateTime.now(), null, false, 0, 20)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test void listReviews_fromAfterTo_shouldReturn400() { + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime to = LocalDateTime.of(2026, 1, 1, 0, 0); + BusinessException ex = assertThrows(BusinessException.class, + () -> reviewService.listReviews(JOB_ID, null, from, to, false, 0, 20)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + } + + @Test void listReviews_onlyFromNoTo_shouldBeAllowed() { + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + LocalDateTime from = LocalDateTime.of(2026, 1, 1, 0, 0); + when(reviewRepository.findPublicReviews(eq(JOB_ID), eq("PUBLISHED"), any(), + isNull(), eq(from), isNull(), eq(false), any())) + .thenReturn(new PageImpl<>(List.of(review))); + var r = reviewService.listReviews(JOB_ID, null, from, null, false, 0, 20); + assertEquals(1, r.getTotalElements()); + } + + @Test void listReviews_nullFilters_shouldDefaultFalse() { + when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).build()); + when(reviewRepository.findPublicReviews(eq(JOB_ID), eq("PUBLISHED"), any(), + isNull(), isNull(), isNull(), eq(false), any())) + .thenReturn(new PageImpl<>(List.of(review))); + var r = reviewService.listReviews(JOB_ID, null, null, null, false, 0, 20); + assertEquals(1, r.getTotalElements()); + } + @Test void updateReview_shouldSucceed() { mockStudentRole(STUDENT_ID); when(reviewRepository.findById(REVIEW_ID)).thenReturn(Optional.of(review));