Compare commits

...

4 Commits

  1. 15
      docs/company-candidate-api.md
  2. 6
      docs/frontend-integration.md
  3. 134
      docs/review-reply-api.md
  4. 82
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java
  5. 7
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java
  6. 3
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java
  7. 5
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  8. 3
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  9. 39
      mydreamjob-server/src/main/java/com/mydreamjob/review/AdminReviewController.java
  10. 60
      mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReply.java
  11. 99
      mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyController.java
  12. 60
      mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyRepository.java
  13. 378
      mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyService.java
  14. 13
      mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewController.java
  15. 23
      mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java
  16. 41
      mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewService.java
  17. 72
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/AdminCompanyReviewReplyResponse.java
  18. 13
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyCreateRequest.java
  19. 40
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyResponse.java
  20. 11
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/CompanyReviewReplyUpdateRequest.java
  21. 22
      mydreamjob-server/src/main/java/com/mydreamjob/review/dto/ReviewReplyStatsResponse.java
  22. 15
      mydreamjob-server/src/main/resources/db/migration/V34__company_review_replies.sql
  23. 77
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java
  24. 467
      mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyServiceTest.java
  25. 2
      mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java
  26. 3
      mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionServiceTest.java
  27. 61
      mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewServiceTest.java

15
docs/company-candidate-api.md

@ -40,7 +40,10 @@ GET /api/v1/company/candidates?[jobId][&status][&keyword][&sort][&order][&page][
"candidateGraduationYear": 2026,
"candidateAvatarUrl": "https://...",
"createdAt": "2026-08-15T10:00:00",
"updatedAt": "2026-08-16T14:00:00"
"updatedAt": "2026-08-16T14:00:00",
"matchScore": 82,
"matchReasons": ["技能匹配", "学历符合", "城市匹配", "综合评分高"],
"matchPercentile": 80
}],
"total": 156,
"page": 0,
@ -101,13 +104,21 @@ GET /api/v1/company/candidates/{applicationId}
"candidateSchool": "清华大学",
"candidateEducation": "本科",
"candidateGraduationYear": 2026,
"candidateSkills": ["Java", "Spring Boot", "MySQL"]
"candidateSkills": ["Java", "Spring Boot", "MySQL"],
"matchScore": 82,
"matchReasons": ["技能匹配", "学历符合", "城市匹配", "综合评分高"],
"matchPercentile": 80
},
"requestId": "abc-123",
"timestamp": "2026-08-17T15:00:00Z"
}
```
> 详情同样**一致返回** `matchScore`(Integer,0-100,不保留小数)、`matchReasons`
> (简短短语数组,如 `["技能匹配","学历符合","城市匹配","综合评分高"]`,不包含技能明细)与 `matchPercentile`
> (Integer,岗位匹配百分位)。`matchPercentile` 口径与列表一致(按同岗位候选人池,`round(countLower/(cohortSize-1)*100)`,不足 2 人省略)。
> 该字段属企业内部筛选信息,学生侧不可见。
### 错误码
| 状态码 | 错误码 | 说明 |

6
docs/frontend-integration.md

@ -167,9 +167,11 @@ Authorization: Bearer <accessToken>
```
**企业候选人列表可按岗位匹配度排序**:`GET /company/candidates?sort=match`。
- 列表项在所有排序下**一致返回** `matchScore`(Integer,0-100 整数)与 `matchReasons`(简短短语数组,如 `["技能匹配","学历符合","城市匹配","综合评分高"]`)。
- 列表项在所有排序下**一致返回** `matchScore`(Integer,0-100 整数)、`matchReasons`(简短短语数组,如 `["技能匹配","学历符合","城市匹配","综合评分高"]`)与 `matchPercentile`(Integer,岗位匹配百分位)。
- `matchReasons` 不包含具体技能明细;`sort=match` 时按 `matchScore` 降序,`sort=createdAt/updatedAt` 时按时间排序但同样携带匹配字段。
- matchScore/matchReasons 属企业内部筛选信息,学生端不可见。
- **候选人详情 `GET /company/candidates/{applicationId}` 同样一致返回** `matchScore`、`matchReasons` 与 `matchPercentile`,供详情页展示匹配度。
- `matchPercentile` 表示候选人按 `matchScore` 超过了同岗位百分之多少的候选人(口径 `round(countLower/(cohortSize-1)*100)`;同岗位不足 2 人时省略)。
- matchScore/matchReasons/matchPercentile 属企业内部筛选信息,学生端不可见。
### 8.2 岗位八维评价

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/*)。

82
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java

@ -33,6 +33,7 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
@ -299,6 +300,7 @@ public class ApplicationService {
List<CandidateItemView> items = content.stream()
.map(row -> buildItem(row, overallScores(pageStudentIds)))
.collect(Collectors.toList());
fillMatchPercentile(items, companyId);
return PageResponse.of(items, result.getTotalElements(), page, size);
}
@ -322,6 +324,7 @@ public class ApplicationService {
int from = Math.min(page * size, all.size());
int to = Math.min(from + size, all.size());
List<CandidateItemView> items = all.subList(from, to);
fillMatchPercentile(items, companyId);
return PageResponse.of(items, all.size(), page, size);
}
@ -345,11 +348,69 @@ public class ApplicationService {
.createdAt(a.getCreatedAt())
.updatedAt(a.getUpdatedAt());
TalentMatchScorer.MatchResult m = talentMatchScorer.match(
j, sp, overallByStudent.get(a.getStudentUserId()));
TalentMatchScorer.MatchResult m = matchOf(row, overallByStudent);
return b.matchScore(m.matchScore()).matchReasons(m.reasons()).build();
}
/** 由候选列表 JOIN 行计算岗位匹配(复用 TalentMatchScorer,供列表项/详/百分位共用) */
private TalentMatchScorer.MatchResult matchOf(Object[] row, Map<Long, java.math.BigDecimal> overallByStudent) {
Application a = (Application) row[0];
Job j = (Job) row[1];
StudentProfile sp = (StudentProfile) row[2];
return talentMatchScorer.match(j, sp, overallByStudent.get(a.getStudentUserId()));
}
/**
* 岗位匹配百分位(企业内部信息):按“同岗位候选人池”以 matchScore 排位,
* 返回 appId -> 超过同岗位百分之多少的候选人(0-100 整数)。
* 口径:round(countLower / (cohortSize - 1) * 100);cohortSize<2 时为 null(省略)。
* cohort = 本企业该岗位的全部投递(不受列表 status/keyword 过滤影响),保证详情与列表一致。
*/
private Map<Long, Integer> matchPercentilesByJob(Long companyId, Collection<Long> jobIds) {
if (jobIds.isEmpty()) return Collections.emptyMap();
// 一次取出本企业全量候选人(复用 match 全量查询),仅保留请求涉及的岗位作为 cohort
List<Object[]> rows = applicationRepository.findCompanyCandidatesForMatch(companyId, null, null, null);
Map<Long, List<Object[]>> byJob = rows.stream()
.filter(r -> jobIds.contains(((Job) r[1]).getId()))
.collect(Collectors.groupingBy(r -> ((Job) r[1]).getId()));
// 批量取 cohort 内所有学生的综合分,供 matchScore 计算(无 N+1)
List<Long> allStudents = byJob.values().stream()
.flatMap(List::stream)
.map(r -> ((Application) r[0]).getStudentUserId())
.distinct().collect(Collectors.toList());
Map<Long, java.math.BigDecimal> overall = overallScores(allStudents);
Map<Long, Integer> result = new HashMap<>();
for (List<Object[]> cohort : byJob.values()) {
List<Integer> scores = new ArrayList<>();
Map<Long, Integer> scoreByApp = new HashMap<>();
for (Object[] row : cohort) {
TalentMatchScorer.MatchResult m = matchOf(row, overall);
scores.add(m.matchScore());
scoreByApp.put(((Application) row[0]).getId(), m.matchScore());
}
if (scores.size() < 2) continue; // 同岗位不足 2 人 → 百分位不可计算
for (Object[] row : cohort) {
Long appId = ((Application) row[0]).getId();
int my = scoreByApp.get(appId);
long lower = scores.stream().filter(s -> s < my).count();
int pct = (int) Math.round(lower * 100.0 / (scores.size() - 1));
result.put(appId, Math.max(0, Math.min(100, pct)));
}
}
return result;
}
/** 为列表项补充岗位匹配百分位(企业内部信息,全部排序一致返回) */
private void fillMatchPercentile(List<CandidateItemView> items, Long companyId) {
if (items.isEmpty()) return;
Set<Long> involvedJobs = items.stream()
.map(CandidateItemView::getJobId).collect(Collectors.toSet());
Map<Long, Integer> pct = matchPercentilesByJob(companyId, involvedJobs);
items.forEach(it -> it.setMatchPercentile(pct.get(it.getApplicationId())));
}
/** 批量计算候选人综合评分:取每个学生最新周期的快照,复用候选评分引擎公式,避免 N+1 */
private Map<Long, java.math.BigDecimal> overallScores(List<Long> studentUserIds) {
if (studentUserIds.isEmpty()) return Collections.emptyMap();
@ -413,7 +474,14 @@ public class ApplicationService {
boolean contactVisible = studentUserId != null
&& privacySettingsService.canViewContact(studentUserId);
return CandidateDetailView.builder()
// 岗位匹配度(企业内部信息,详情一致返回):复用列表面板同一计算逻辑
Long applicantStudentId = app.getStudentUserId();
java.math.BigDecimal overall = overallScores(
applicantStudentId != null ? List.of(applicantStudentId) : Collections.emptyList())
.get(applicantStudentId);
TalentMatchScorer.MatchResult match = talentMatchScorer.match(job, sp, overall);
CandidateDetailView detail = CandidateDetailView.builder()
.applicationId(app.getId())
.jobId(app.getJobId())
.jobTitle(job.getTitle())
@ -435,7 +503,15 @@ public class ApplicationService {
// 联系方式:仅当学生 contactVisible=true 时返回真实值,否则省略
.candidatePhone(contactVisible && u != null ? u.getPhone() : null)
.candidateEmail(contactVisible && u != null ? u.getEmail() : null)
// 岗位匹配度与理由:企业内部信息,详情一致返回
.matchScore(match.matchScore())
.matchReasons(match.reasons())
.build();
// 岗位匹配百分位(企业内部信息):按同岗位候选人池以 matchScore 计算,与列表口径一致
Map<Long, Integer> pct = matchPercentilesByJob(companyId, List.of(job.getId()));
detail.setMatchPercentile(pct.get(app.getId()));
return detail;
}
/**

7
mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java

@ -52,4 +52,11 @@ public class CandidateDetailView {
// 联系方式(仅当学生 contactVisible=true 时由 Service 填充,否则保持 null 被省略)
private String candidatePhone;
private String candidateEmail;
// 岗位匹配(企业内部筛选信息,详情一致返回,学生不可见)
private Integer matchScore;
private List<String> matchReasons;
/** 岗位匹配百分位(0-100 整数):超过同岗位百分之多少的候选人;同岗位<2人时为 null 省略 */
private Integer matchPercentile;
}

3
mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java

@ -36,4 +36,7 @@ public class CandidateItemView {
/** 岗位匹配理由(简短短语,企业内部信息),全部排序下一致返回 */
private List<String> matchReasons;
/** 岗位匹配百分位(0-100 整数):超过同岗位百分之多少的候选人;同岗位<2人时为 null 省略 */
private Integer matchPercentile;
}

5
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"),

3
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()
// 岗位公开查询

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

99
mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyController.java

@ -0,0 +1,99 @@
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 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;
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)));
}
@Operation(summary = "企业回复率统计", description = "仅企业;返回本公司本月回复率、行业平均回复率、本月待回复条数")
@GetMapping("/company/review-reply/stats")
public ResponseEntity<ApiResponse<ReviewReplyStatsResponse>> 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) {
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");
}
}

60
mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyRepository.java

@ -0,0 +1,60 @@
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.Collection;
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);
/** 按月统计同一行业下各企业评价总数与已回复数;industry 为 null 时不限行业 */
@Query("SELECT j.companyId AS companyId, COUNT(r) AS totalCnt, "
+ " COUNT(CASE WHEN rr.id IS NOT NULL THEN 1 END) AS repliedCnt "
+ " FROM JobReview r "
+ " JOIN Job j ON j.id = r.jobId "
+ " LEFT JOIN CompanyReviewReply rr ON rr.reviewId = r.id "
+ " WHERE (:industry IS NULL OR"
+ " j.companyId IN (SELECT cc.id FROM Company cc WHERE cc.industry = :industry))"
+ " AND r.status = 'PUBLISHED'"
+ " AND r.moderationStatus IN :moderationStatuses"
+ " AND r.createdAt >= :start AND r.createdAt < :end"
+ " GROUP BY j.companyId")
List<Object[]> findMonthlyReplyStats(@Param("industry") String industry,
@Param("moderationStatuses") Collection<String> moderationStatuses,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
}

378
mydreamjob-server/src/main/java/com/mydreamjob/review/CompanyReviewReplyService.java

@ -0,0 +1,378 @@
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 com.mydreamjob.review.dto.ReviewReplyStatsResponse;
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.time.LocalDate;
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;
}
// ── 回复率统计(企业侧) ──────────────────────────────────
/**
* 企业回复率统计:本月回复率 / 行业平均回复率(逐企业平均)/ 本月待回复条数。
* 仅统计本月、属于本公司岗位、可回复的公开评价(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<Object[]> 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
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();
}
}

13
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<ApiResponse<Map<String, Object>>> 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<Map<String, Object>> result = reviewService.listReviews(jobId, page, size);
Page<Map<String, Object>> result = reviewService.listReviews(
jobId, ratingType, createdFrom, createdTo, unreplied, page, size);
Map<String, Object> data = new HashMap<>();
data.put("items", result.getContent());
data.put("total", result.getTotalElements());

23
mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java

@ -41,6 +41,29 @@ public interface JobReviewRepository extends JpaRepository<JobReview, Long> {
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<JobReview> findPublicReviews(@Param("jobId") Long jobId,
@Param("status") String status,
@Param("moderationStatuses") Collection<String> 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)"

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

@ -39,9 +39,11 @@ 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<String> ALLOWED_STATUSES = Set.of("PUBLISHED", "HIDDEN");
private static final Set<String> 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;
@ -66,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);
@ -140,11 +152,20 @@ public class JobReviewService {
// ── 查询 ──────────────────────────────────────────────────
public Page<Map<String, Object>> listReviews(Long jobId, int page, int size) {
return listReviews(jobId, null, null, null, false, page, size);
}
/** 公开评价列表(含筛选):评分档/创建时间区间/未回复;均可不传 */
public Page<Map<String, Object>> listReviews(Long jobId, String ratingType,
LocalDateTime createdFrom, LocalDateTime createdTo,
boolean unreplied, int page, int size) {
assertJobExists(jobId);
assertValidPage(page, size);
Page<JobReview> result = reviewRepository
.findByJobIdAndStatusAndModerationStatusInOrderByCreatedAtDesc(
jobId, "PUBLISHED", PUBLIC_MODERATION_STATUSES, PageRequest.of(page, size));
assertValidRatingType(ratingType);
assertValidTimeRange(createdFrom, createdTo);
Page<JobReview> result = reviewRepository.findPublicReviews(
jobId, "PUBLISHED", PUBLIC_MODERATION_STATUSES,
ratingType, createdFrom, createdTo, unreplied, PageRequest.of(page, size));
List<Long> ids = result.getContent().stream().map(JobReview::getId).toList();
Map<Long, Map<String, Integer>> dimsByReview = batchLoadDimensionMap(ids);
return result.map(r -> toView(r, dimsByReview.getOrDefault(r.getId(), Map.of())));
@ -493,6 +514,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;
}

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

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

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

77
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java

@ -24,12 +24,14 @@ import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import java.util.*;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@ -292,6 +294,49 @@ class ApplicationServiceTest {
assertEquals(1, resp.getItems().size());
}
@Test void listCompanyCandidates_shouldComputeMatchPercentile() {
// 同一岗位 3 名候选人,matchScore 90/60/30 → 百分位 100/50/0(口径 countLower/(n-1))
Object[] high = buildRow(200L, JOB_ID, "Java", "SUBMITTED", "张三", "清华", "本科", null, null);
Object[] mid = buildRow(201L, JOB_ID, "Java", "SUBMITTED", "李四", "北大", "硕士", null, null);
Object[] low = buildRow(202L, JOB_ID, "Java", "SUBMITTED", "王五", "复旦", "本科", null, null);
StudentProfile spHigh = (StudentProfile) high[2];
StudentProfile spMid = (StudentProfile) mid[2];
StudentProfile spLow = (StudentProfile) low[2];
when(applicationRepository.findCompanyCandidates(anyLong(), any(), any(), any(), any(Pageable.class)))
.thenReturn(mockCandidatePage(high, mid, low));
// percentile cohort = 该岗位全量候选人(同岗位 3 人)
when(applicationRepository.findCompanyCandidatesForMatch(anyLong(), any(), any(), any()))
.thenReturn(List.of(high, mid, low));
when(talentMatchScorer.match(any(), argThat(sp -> sp == spHigh), any()))
.thenReturn(new TalentMatchScorer.MatchResult(90, List.of("x")));
when(talentMatchScorer.match(any(), argThat(sp -> sp == spMid), any()))
.thenReturn(new TalentMatchScorer.MatchResult(60, List.of("x")));
when(talentMatchScorer.match(any(), argThat(sp -> sp == spLow), any()))
.thenReturn(new TalentMatchScorer.MatchResult(30, List.of("x")));
PageResponse<CandidateItemView> resp = applicationService.listCompanyCandidates(
COMPANY_ID, null, null, null, null, null, 0, 20);
Map<Long, Integer> pctByApp = resp.getItems().stream()
.collect(Collectors.toMap(CandidateItemView::getApplicationId, CandidateItemView::getMatchPercentile));
assertEquals(Integer.valueOf(100), pctByApp.get(200L)); // 2/(3-1)*100
assertEquals(Integer.valueOf(50), pctByApp.get(201L)); // 1/(3-1)*100
assertEquals(Integer.valueOf(0), pctByApp.get(202L)); // 0/(3-1)*100
}
@Test void listCompanyCandidates_cohortLessThan2_shouldOmitPercentile() {
// 同岗位仅 1 名候选人 → 百分位不可计算 → null(配合 NON_NULL 省略)
Object[] row = buildRow(200L, JOB_ID, "Java", "SUBMITTED", "张三", "清华", "本科", null, null);
when(applicationRepository.findCompanyCandidates(anyLong(), any(), any(), any(), any(Pageable.class)))
.thenReturn(mockCandidatePage(row));
when(applicationRepository.findCompanyCandidatesForMatch(anyLong(), any(), any(), any()))
.thenReturn(Collections.singletonList(row));
PageResponse<CandidateItemView> resp = applicationService.listCompanyCandidates(
COMPANY_ID, null, null, null, null, null, 0, 20);
assertNull(resp.getItems().get(0).getMatchPercentile());
}
// ── 企业候选人详情 ─────────────────────────────────────────
@Test void getCompanyCandidateDetail_shouldReturnDetail() {
@ -322,10 +367,42 @@ class ApplicationServiceTest {
assertEquals("清华", detail.getCandidateSchool());
assertEquals("13800000001", detail.getCandidatePhone());
assertEquals("stu@example.com", detail.getCandidateEmail());
// 详情一致返回岗位匹配度与理由(企业内部分数,与列表面板同源)
assertEquals(Integer.valueOf(88), detail.getMatchScore());
assertEquals(List.of("x"), detail.getMatchReasons());
// 成功详情查询不产生额外 Repository 查询(无 N+1)
verify(applicationRepository, never()).findById(anyLong());
}
@Test void getCompanyCandidateDetail_shouldComputeMatchPercentile() {
// 目标候选人同岗位另有 1 人且 matchScore 更低 → 百分位 round(1/(2-1)*100)=100
Application app = Application.builder().id(APP_ID).jobId(JOB_ID)
.studentUserId(STUDENT_ID).status("SUBMITTED").build();
com.mydreamjob.job.Job job = com.mydreamjob.job.Job.builder()
.id(JOB_ID).companyId(COMPANY_ID).title("Java").build();
StudentProfile mine = StudentProfile.builder().userId(STUDENT_ID).realName("张三").build();
Object[] myRow = new Object[]{app, job, mine, null};
com.mydreamjob.auth.User otherUser = mock(com.mydreamjob.auth.User.class);
StudentProfile other = StudentProfile.builder().userId(999L).realName("李四").build();
Object[] otherRow = new Object[]{
Application.builder().id(999L).jobId(JOB_ID).studentUserId(999L).status("SUBMITTED").build(),
job, other, otherUser};
when(applicationRepository.findCandidateDetailById(APP_ID)).thenReturn(Collections.singletonList(myRow));
when(privacySettingsService.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)).thenReturn(false);
when(privacySettingsService.canViewContact(STUDENT_ID)).thenReturn(false);
// percentile cohort = 该岗位全量候选人(目标 + 同岗位另 1 人)
when(applicationRepository.findCompanyCandidatesForMatch(anyLong(), any(), any(), any()))
.thenReturn(List.of(myRow, otherRow));
when(talentMatchScorer.match(any(), argThat(sp -> sp == mine), any()))
.thenReturn(new TalentMatchScorer.MatchResult(90, List.of("x")));
when(talentMatchScorer.match(any(), argThat(sp -> sp == other), any()))
.thenReturn(new TalentMatchScorer.MatchResult(40, List.of("x")));
CandidateDetailView detail = applicationService.getCompanyCandidateDetail(COMPANY_ID, APP_ID);
assertEquals(Integer.valueOf(100), detail.getMatchPercentile());
}
@Test void getCompanyCandidateDetail_wrongCompany_shouldThrow() {
// 投递存在,但岗位 companyId 与当前企业不匹配 → APPLICATION_OWNER_MISMATCH
Application app = Application.builder().id(APP_ID).jobId(JOB_ID)

467
mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewReplyServiceTest.java

@ -0,0 +1,467 @@
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 com.mydreamjob.review.dto.ReviewReplyStatsResponse;
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());
}
// ── 回复率统计(企业侧) ──────────────────────────────────
@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.<Object[]>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.<Object[]>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;
}
}

2
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));

3
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(),

61
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)
@ -35,6 +37,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;
@ -50,7 +53,11 @@ 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()))
.thenReturn(Optional.empty());
// 审核默认通过(AUTO_APPROVED);命中规则测试单独覆写
lenient().when(moderationService.moderate(any(), any(), any(), any(), any(), any(), any()))
.thenReturn(new ReviewModerationService.ModerationResult(
@ -141,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));

Loading…
Cancel
Save