Compare commits
4 Commits
21b853b759
...
d00a63069d
| Author | SHA1 | Date |
|---|---|---|
|
|
d00a63069d | 2 months ago |
|
|
0f194cebef | 2 months ago |
|
|
2955305f02 | 2 months ago |
|
|
72465f8c3b | 2 months ago |
27 changed files with 1736 additions and 15 deletions
@ -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/*)。 |
|||
@ -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; |
|||
} |
|||
@ -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"); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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='企业对岗位评价的回复'; |
|||
@ -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; |
|||
} |
|||
} |
|||
Loading…
Reference in new issue