diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyController.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyController.java index 07194be..e24580e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyController.java @@ -4,6 +4,7 @@ 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 com.mydreamjob.review.dto.ReviewReplyStatsResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -66,6 +67,14 @@ public class CompanyReviewReplyController { return ResponseEntity.ok(ApiResponse.ok(Map.of("message", "reply deleted"), getRequestId(request))); } + @Operation(summary = "企业回复率统计", description = "仅企业;返回本公司本月回复率、行业平均回复率、本月待回复条数") + @GetMapping("/company/review-reply/stats") + public ResponseEntity> stats(HttpServletRequest request) { + Long userId = resolveUserId(request); + ReviewReplyStatsResponse response = replyService.getReviewReplyStats(userId); + return ResponseEntity.ok(ApiResponse.ok(response, getRequestId(request))); + } + private Long resolveUserId(HttpServletRequest request) { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyRepository.java index 4b64896..595bf95 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -39,4 +40,21 @@ public interface CompanyReviewReplyRepository extends JpaRepository= :start AND r.createdAt < :end" + + " GROUP BY j.companyId") + List findMonthlyReplyStats(@Param("industry") String industry, + @Param("moderationStatuses") Collection moderationStatuses, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyService.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyService.java index f90272c..94a94d3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyService.java @@ -13,6 +13,7 @@ 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 com.mydreamjob.review.dto.ReviewReplyStatsResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -21,6 +22,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -137,6 +139,52 @@ public class CompanyReviewReplyService { return result; } + // ── 回复率统计(企业侧) ────────────────────────────────── + + /** + * 企业回复率统计:本月回复率 / 行业平均回复率(逐企业平均)/ 本月待回复条数。 + * 仅统计本月、属于本公司岗位、可回复的公开评价(AUTO_APPROVED/APPROVED); + * 评价存在任意回复记录即视为已回复。 + */ + @Transactional(readOnly = true) + public ReviewReplyStatsResponse getReviewReplyStats(Long userId) { + Company company = companyRepository.findByOwnerUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN)); + LocalDateTime start = LocalDate.now().withDayOfMonth(1).atStartOfDay(); + LocalDateTime end = start.plusMonths(1); + String industry = company.getIndustry(); + boolean hasIndustry = industry != null && !industry.isBlank(); + List rows = replyRepository.findMonthlyReplyStats( + hasIndustry ? industry : null, + List.of(JobReview.MODERATION_AUTO_APPROVED, JobReview.MODERATION_APPROVED), + start, end); + long myTotal = 0, myReplied = 0; + double rateSum = 0; + long rateCount = 0; + for (Object[] row : rows) { + long cid = ((Number) row[0]).longValue(); + long total = ((Number) row[1]).longValue(); + long replied = ((Number) row[2]).longValue(); + if (cid == company.getId()) { + myTotal = total; + myReplied = replied; + } + if (total > 0) { + rateSum += (double) replied / total; + rateCount++; + } + } + int monthRate = myTotal == 0 ? 0 : (int) Math.round(myReplied * 100.0 / myTotal); + int unreplied = (int) (myTotal - myReplied); + int industryAvg = (!hasIndustry || rateCount == 0) + ? 0 : (int) Math.round(rateSum / rateCount * 100.0); + return ReviewReplyStatsResponse.builder() + .monthReplyRate(monthRate) + .industryAvgReplyRate(industryAvg) + .unrepliedCount(unreplied) + .build(); + } + // ── 更新回复(企业侧) ────────────────────────────────────── @Transactional diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewReplyStatsResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewReplyStatsResponse.java new file mode 100644 index 0000000..aded935 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewReplyStatsResponse.java @@ -0,0 +1,22 @@ +package com.mydreamjob.review.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 企业回复率统计响应:本月回复率 / 行业平均回复率 / 本月待回复条数 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReviewReplyStatsResponse { + /** 本月回复率(百分比整数,如 80 表示 80%) */ + private Integer monthReplyRate; + /** 行业平均回复率(百分比整数) */ + private Integer industryAvgReplyRate; + /** 本月待回复评价条数 */ + private Integer unrepliedCount; +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyServiceTest.java index e6a4451..8a66cf2 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyServiceTest.java @@ -12,6 +12,7 @@ import com.mydreamjob.job.JobService; import com.mydreamjob.review.dto.AdminCompanyReviewReplyResponse; import com.mydreamjob.review.dto.CompanyReviewReplyCreateRequest; import com.mydreamjob.review.dto.CompanyReviewReplyUpdateRequest; +import com.mydreamjob.review.dto.ReviewReplyStatsResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -395,6 +396,71 @@ class CompanyReviewReplyServiceTest { assertEquals(COMPANY_USER_ID, v.getCompanyUserId()); } + // ── 回复率统计(企业侧) ────────────────────────────────── + + @Test void getReviewReplyStats_shouldComputeMonthRateAndIndustryAvg() { + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID) + .ownerUserId(COMPANY_USER_ID).industry("IT").build())); + when(replyRepository.findMonthlyReplyStats(any(), any(), any(), any())).thenReturn(List.of( + new Object[]{COMPANY_ID, 5L, 4L}, // 当前企业:80% + new Object[]{OTHER_COMPANY_ID, 4L, 2L})); // 同行企业:50% + + ReviewReplyStatsResponse r = replyService.getReviewReplyStats(COMPANY_USER_ID); + + assertEquals(80, r.getMonthReplyRate()); + assertEquals(65, r.getIndustryAvgReplyRate()); // (0.8+0.5)/2 = 0.65 + assertEquals(1, r.getUnrepliedCount()); + } + + @Test void getReviewReplyStats_noReplies_shouldReturnZeroRate() { + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID) + .ownerUserId(COMPANY_USER_ID).industry("IT").build())); + when(replyRepository.findMonthlyReplyStats(any(), any(), any(), any())) + .thenReturn(List.of(new Object[]{COMPANY_ID, 2L, 0L})); + + ReviewReplyStatsResponse r = replyService.getReviewReplyStats(COMPANY_USER_ID); + + assertEquals(0, r.getMonthReplyRate()); + assertEquals(2, r.getUnrepliedCount()); + } + + @Test void getReviewReplyStats_noReviewsThisMonth_shouldReturnZeros() { + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID) + .ownerUserId(COMPANY_USER_ID).industry("IT").build())); + when(replyRepository.findMonthlyReplyStats(any(), any(), any(), any())).thenReturn(List.of()); + + ReviewReplyStatsResponse r = replyService.getReviewReplyStats(COMPANY_USER_ID); + + assertEquals(0, r.getMonthReplyRate()); + assertEquals(0, r.getIndustryAvgReplyRate()); + assertEquals(0, r.getUnrepliedCount()); + } + + @Test void getReviewReplyStats_companyWithoutIndustry_shouldSetIndustryAvgZero() { + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) + .thenReturn(Optional.of(Company.builder().id(COMPANY_ID) + .ownerUserId(COMPANY_USER_ID).industry(null).build())); + when(replyRepository.findMonthlyReplyStats(any(), any(), any(), any())) + .thenReturn(List.of(new Object[]{COMPANY_ID, 5L, 4L})); + + ReviewReplyStatsResponse r = replyService.getReviewReplyStats(COMPANY_USER_ID); + + assertEquals(80, r.getMonthReplyRate()); + assertEquals(0, r.getIndustryAvgReplyRate()); + assertEquals(1, r.getUnrepliedCount()); + } + + @Test void getReviewReplyStats_companyUserWithoutCompany_shouldReturn403() { + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.empty()); + + BusinessException ex = assertThrows(BusinessException.class, + () -> replyService.getReviewReplyStats(COMPANY_USER_ID)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); + } + private Long ADMIN_ROLE_ID() { return 1L; }