Prechádzať zdrojové kódy

test: 完成360环评生命周期验证

wangkangyjy 15 hodín pred
rodič
commit
abab51a2b5

+ 69 - 7
backend/src/main/java/com/emoon/okr/service/PeerReviewService.java

@@ -173,6 +173,10 @@ public class PeerReviewService {
             }
             return invitation;
         }).toList();
+        publisher.publishEvent(new NotificationEvent(
+                this, request.getLeaderId(), "PEER_REVIEW_LIST_PENDING",
+                "360 环评名单待审核", "员工提交了评价人名单,请及时审核",
+                "peer_review_request", request.getId()));
         return employeeProgress(request, invitations);
     }
 
@@ -292,6 +296,9 @@ public class PeerReviewService {
         PeerReviewInvitation invitation = requireReviewerInvitation(invitationId, reviewerId, false);
         PeerReviewRequest request = requireRequest(invitation.getRequestId());
         requireReviewOpen(request.getPeriodId());
+        if (invalidateIfReviewerInactive(invitation, request.getPeriodId())) {
+            return reviewerInvitationSummary(invitation);
+        }
         validateResponse(body, submit);
 
         invitation.setGoalContributionScore(body.getGoalContributionScore());
@@ -304,6 +311,12 @@ public class PeerReviewService {
             invitation.setSubmittedAt(LocalDateTime.now());
         }
         invitationMapper.updateById(invitation);
+        if (submit && canFinalize(request.getPeriodId(), requestInvitations(request.getId()))) {
+            publisher.publishEvent(new NotificationEvent(
+                    this, request.getLeaderId(), "PEER_REVIEW_READY",
+                    "360 环评已收到 2 份评价", "该员工已收到至少 2 份有效评价,可作为终评参考",
+                    "peer_review_request", request.getId()));
+        }
         return reviewerInvitationSummary(invitation);
     }
 
@@ -399,6 +412,8 @@ public class PeerReviewService {
         if (!Objects.equals(request.getLeaderId(), leaderId)) {
             throw new BusinessException(403, "仅周期冻结 Leader 可审核评价人名单");
         }
+        AssessmentPeriod period = requireAssessingPeriod(request.getPeriodId());
+        requireInvitationOpen(period);
         String trimmedReason = reason == null ? "" : reason.trim();
         if (!approved && trimmedReason.isEmpty()) {
             throw new BusinessException("驳回评价人名单时必须填写原因");
@@ -409,6 +424,7 @@ public class PeerReviewService {
                         .eq(PeerReviewInvitation::getRequestId, requestId));
         LocalDateTime now = LocalDateTime.now();
         if (approved) {
+            validateReviewerSet(request, invitations);
             request.setStatus(REQUEST_IN_PROGRESS);
             request.setApprovedAt(now);
             request.setRejectionReason(null);
@@ -443,6 +459,11 @@ public class PeerReviewService {
     @Transactional
     public PeerReviewDto.InvitationSummary acceptInvitation(Long invitationId, Long reviewerId) {
         PeerReviewInvitation invitation = requirePendingInvitation(invitationId, reviewerId);
+        PeerReviewRequest request = requireRequest(invitation.getRequestId());
+        requireReviewOpen(request.getPeriodId());
+        if (invalidateIfReviewerInactive(invitation, request.getPeriodId())) {
+            return reviewerInvitationSummary(invitation);
+        }
         invitation.setStatus(INVITATION_ACCEPTED);
         invitation.setRespondedAt(LocalDateTime.now());
         invitationMapper.updateById(invitation);
@@ -456,21 +477,62 @@ public class PeerReviewService {
             throw new BusinessException("拒绝邀请时必须填写原因");
         }
         PeerReviewInvitation invitation = requirePendingInvitation(invitationId, reviewerId);
+        PeerReviewRequest request = requireRequest(invitation.getRequestId());
+        requireReviewOpen(request.getPeriodId());
+        if (invalidateIfReviewerInactive(invitation, request.getPeriodId())) {
+            return reviewerInvitationSummary(invitation);
+        }
         invitation.setStatus(INVITATION_DECLINED);
         invitation.setDeclineReason(trimmedReason);
         invitation.setRespondedAt(LocalDateTime.now());
         invitationMapper.updateById(invitation);
 
-        PeerReviewRequest request = requestMapper.selectById(invitation.getRequestId());
-        if (request != null) {
-            publisher.publishEvent(new NotificationEvent(
-                    this, request.getSubjectUserId(), "PEER_REVIEW_INVITATION_DECLINED",
-                    "360 环评邀请被拒绝", "一名同事拒绝了邀请,请及时补充评价人",
-                    "peer_review_request", request.getId()));
-        }
+        publisher.publishEvent(new NotificationEvent(
+                this, request.getSubjectUserId(), "PEER_REVIEW_INVITATION_DECLINED",
+                "360 环评邀请被拒绝", "一名同事拒绝了邀请,请及时补充评价人",
+                "peer_review_request", request.getId()));
         return reviewerInvitationSummary(invitation);
     }
 
+    private void validateReviewerSet(PeerReviewRequest request, List<PeerReviewInvitation> invitations) {
+        List<PeerReviewInvitation> effective = invitations.stream()
+                .filter(item -> !INVITATION_DECLINED.equals(item.getStatus())
+                        && !INVITATION_INVALIDATED.equals(item.getStatus()))
+                .toList();
+        if (effective.size() < 3 || effective.size() > 5) {
+            throw new BusinessException("有效评价人须为 3 至 5 人");
+        }
+        List<PeriodParticipant> participants = periodParticipants(request.getPeriodId());
+        PeriodParticipant subject = requireActiveParticipant(participants, request.getSubjectUserId());
+        Map<Long, PeriodParticipant> byUserId = participants.stream().collect(Collectors.toMap(
+                PeriodParticipant::getUserId, Function.identity(), (left, right) -> left));
+        List<PeriodParticipant> reviewers = effective.stream().map(invitation -> {
+            PeriodParticipant reviewer = byUserId.get(invitation.getReviewerId());
+            if (reviewer == null || !isEligible(reviewer, subject)) {
+                throw new BusinessException("评价人必须是本周期有效参与人");
+            }
+            return reviewer;
+        }).toList();
+        boolean hasSameDepartment = reviewers.stream()
+                .anyMatch(item -> Objects.equals(item.getDepartmentIdSnapshot(), subject.getDepartmentIdSnapshot()));
+        boolean hasCrossDepartment = reviewers.stream()
+                .anyMatch(item -> !Objects.equals(item.getDepartmentIdSnapshot(), subject.getDepartmentIdSnapshot()));
+        if (!hasSameDepartment || !hasCrossDepartment) {
+            throw new BusinessException("评价人名单须同时包含同部门和跨部门同事");
+        }
+    }
+
+    private boolean invalidateIfReviewerInactive(PeerReviewInvitation invitation, Long periodId) {
+        boolean active = periodParticipants(periodId).stream().anyMatch(item ->
+                Objects.equals(item.getUserId(), invitation.getReviewerId()) && ACTIVE.equals(item.getStatus()));
+        if (active) {
+            return false;
+        }
+        invitation.setStatus(INVITATION_INVALIDATED);
+        invitationMapper.updateById(invitation);
+        return true;
+    }
+
     private AssessmentPeriod requireAssessingPeriod(Long periodId) {
         AssessmentPeriod period = periodMapper.selectById(periodId);
         if (period == null || period.getStatus() != PeriodStatus.ASSESSING) {

+ 289 - 0
backend/src/test/java/com/emoon/okr/integration/PeerReviewLifecycleApiIntegrationTest.java

@@ -0,0 +1,289 @@
+package com.emoon.okr.integration;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.emoon.okr.entity.AssessmentPeriod;
+import com.emoon.okr.entity.PeriodParticipant;
+import com.emoon.okr.entity.SysDepartment;
+import com.emoon.okr.entity.SysUser;
+import com.emoon.okr.enums.PeriodStatus;
+import com.emoon.okr.enums.PeriodType;
+import com.emoon.okr.enums.UserRole;
+import com.emoon.okr.mapper.AssessmentPeriodMapper;
+import com.emoon.okr.mapper.PeriodParticipantMapper;
+import com.emoon.okr.mapper.SysDepartmentMapper;
+import com.emoon.okr.mapper.SysUserMapper;
+import com.emoon.okr.security.JwtTokenProvider;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
+@TestPropertySource(properties = {
+        "spring.datasource.url=jdbc:sqlite:file:peer_review_api_lifecycle?mode=memory&cache=shared",
+        "app.seed.demo-data-enabled=false"
+})
+class PeerReviewLifecycleApiIntegrationTest {
+
+    @Autowired MockMvc mockMvc;
+    @Autowired ObjectMapper objectMapper;
+    @Autowired JwtTokenProvider tokenProvider;
+    @Autowired PasswordEncoder passwordEncoder;
+    @Autowired AssessmentPeriodMapper periodMapper;
+    @Autowired PeriodParticipantMapper participantMapper;
+    @Autowired SysDepartmentMapper departmentMapper;
+    @Autowired SysUserMapper userMapper;
+
+    @Test
+    void completesPeerReviewLifecycleWithoutLeakingResponses() throws Exception {
+        SysDepartment product = department("产品部");
+        SysDepartment delivery = department("交付部");
+        SysUser admin = user("环评管理员", UserRole.HR_ADMIN, null, null);
+        SysUser leader = user("冻结Leader", UserRole.DEPT_LEADER, product.getId(), null);
+        SysUser subject = user("被评价人", UserRole.EMPLOYEE, product.getId(), leader.getId());
+        SysUser sameDept = user("同部门同事", UserRole.EMPLOYEE, product.getId(), leader.getId());
+        SysUser crossDept = user("跨部门同事", UserRole.EMPLOYEE, delivery.getId(), null);
+        SysUser declining = user("婉拒同事", UserRole.EMPLOYEE, delivery.getId(), null);
+        SysUser replacement = user("补邀同事", UserRole.EMPLOYEE, delivery.getId(), null);
+        SysUser directReport = user("直属下属", UserRole.EMPLOYEE, product.getId(), subject.getId());
+        SysUser newLeader = user("实时新Leader", UserRole.DEPT_LEADER, product.getId(), null);
+
+        AssessmentPeriod period = assessingPeriod();
+        periodMapper.insert(period);
+        participant(period, subject, leader.getId(), leader.getId());
+        participant(period, leader, null, null);
+        participant(period, sameDept, leader.getId(), null);
+        participant(period, crossDept, null, null);
+        participant(period, declining, null, null);
+        participant(period, replacement, null, null);
+        participant(period, directReport, subject.getId(), null);
+
+        String adminToken = token(admin);
+        String leaderToken = token(leader);
+        String subjectToken = token(subject);
+        String sameToken = token(sameDept);
+        String crossToken = token(crossDept);
+        String decliningToken = token(declining);
+        String newLeaderToken = token(newLeader);
+
+        postData(subjectToken, "/api/scores/self", scoreBody(period.getId(), null));
+
+        JsonNode candidates = getData(subjectToken,
+                "/api/peer-reviews/my/candidates?periodId=" + period.getId());
+        List<Long> candidateIds = objectMapper.convertValue(candidates.findValues("userId"),
+                objectMapper.getTypeFactory().constructCollectionType(List.class, Long.class));
+        assertTrue(candidateIds.containsAll(List.of(sameDept.getId(), crossDept.getId(), declining.getId())));
+        assertFalse(candidateIds.contains(subject.getId()));
+        assertFalse(candidateIds.contains(leader.getId()));
+        assertFalse(candidateIds.contains(directReport.getId()));
+
+        JsonNode request = postData(subjectToken, "/api/peer-reviews/my/requests", Map.of(
+                "periodId", period.getId(),
+                "reviewerIds", List.of(sameDept.getId(), crossDept.getId(), declining.getId())
+        ));
+        long requestId = request.get("requestId").asLong();
+        putData(leaderToken, "/api/peer-reviews/leader/requests/" + requestId + "/review",
+                Map.of("approved", true));
+
+        JsonNode leaderSummary = getData(leaderToken, leaderSummaryPath(period, subject));
+        long sameInvitationId = invitationId(leaderSummary, sameDept.getId());
+        long crossInvitationId = invitationId(leaderSummary, crossDept.getId());
+        long decliningInvitationId = invitationId(leaderSummary, declining.getId());
+
+        postData(decliningToken, "/api/peer-reviews/invitations/" + decliningInvitationId + "/decline",
+                Map.of("reason", "本周期合作接触不足"));
+        postData(subjectToken, "/api/peer-reviews/my/supplements", Map.of(
+                "periodId", period.getId(), "reviewerId", replacement.getId()));
+        putData(leaderToken, "/api/peer-reviews/leader/requests/" + requestId + "/review",
+                Map.of("approved", true));
+
+        postData(sameToken, "/api/peer-reviews/invitations/" + sameInvitationId + "/accept", Map.of());
+        putData(sameToken, "/api/peer-reviews/invitations/" + sameInvitationId + "/submit",
+                response(4, 5, 4, "跨部门项目响应及时并主动同步风险", "可以增加阶段性复盘和书面沉淀"));
+
+        JsonNode blocked = postApi(leaderToken, "/api/scores/superior",
+                scoreBody(period.getId(), subject.getId()));
+        assertEquals(500, blocked.get("code").asInt());
+
+        postData(crossToken, "/api/peer-reviews/invitations/" + crossInvitationId + "/accept", Map.of());
+        putData(crossToken, "/api/peer-reviews/invitations/" + crossInvitationId + "/submit",
+                response(5, 4, 4, "目标推进稳定且交付质量符合预期", "复杂任务开始前可以更早对齐依赖"));
+
+        period.setPeerReviewDeadline(LocalDateTime.now().minusMinutes(1));
+        periodMapper.updateById(period);
+        postData(leaderToken, "/api/scores/superior", scoreBody(period.getId(), subject.getId()));
+
+        JsonNode employeeProgress = getData(subjectToken,
+                "/api/peer-reviews/my/progress?periodId=" + period.getId());
+        assertEquals(2, employeeProgress.get("submittedCount").asInt());
+        assertFalse(hasAnyField(employeeProgress, "goalContributionScore", "collaborationScore",
+                "accountabilityScore", "strengths", "improvementSuggestion"));
+
+        JsonNode forbiddenWorkspace = getApi(crossToken,
+                "/api/peer-reviews/invitations/" + sameInvitationId);
+        assertEquals(403, forbiddenWorkspace.get("code").asInt());
+
+        subject.setSuperiorId(newLeader.getId());
+        userMapper.updateById(subject);
+        JsonNode realtimeLeaderDenied = getApi(newLeaderToken, leaderSummaryPath(period, subject));
+        assertEquals(403, realtimeLeaderDenied.get("code").asInt());
+
+        JsonNode statistics = getData(adminToken,
+                "/api/peer-reviews/admin/statistics?periodId=" + period.getId());
+        assertEquals(2, statistics.get("submittedResponseCount").asInt());
+        assertFalse(hasAnyField(statistics, "reviewerName", "responses", "strengths", "improvementSuggestion"));
+
+        JsonNode finalLeaderSummary = getData(leaderToken, leaderSummaryPath(period, subject));
+        assertEquals("同部门同事", finalLeaderSummary.get("responses").get(0).get("reviewerName").asText());
+        assertTrue(finalLeaderSummary.toString().contains("跨部门项目响应及时并主动同步风险"));
+    }
+
+    private AssessmentPeriod assessingPeriod() {
+        AssessmentPeriod period = new AssessmentPeriod();
+        period.setName("360 环评生命周期");
+        period.setPeriodType(PeriodType.MONTHLY);
+        period.setStartDate(LocalDate.now().minusDays(30));
+        period.setEndDate(LocalDate.now());
+        period.setStatus(PeriodStatus.ASSESSING);
+        period.setPeerInvitationDeadline(LocalDateTime.now().plusDays(1));
+        period.setPeerReviewDeadline(LocalDateTime.now().plusDays(2));
+        return period;
+    }
+
+    private void participant(AssessmentPeriod period, SysUser user, Long superiorId, Long evaluatorId) {
+        PeriodParticipant item = new PeriodParticipant();
+        item.setPeriodId(period.getId());
+        item.setUserId(user.getId());
+        item.setStatus("ACTIVE");
+        item.setDepartmentIdSnapshot(user.getDepartmentId());
+        item.setSuperiorIdSnapshot(superiorId);
+        item.setRoleSnapshot(user.getRole().name());
+        item.setEvaluatorId(evaluatorId);
+        participantMapper.insert(item);
+    }
+
+    private SysDepartment department(String name) {
+        SysDepartment department = new SysDepartment();
+        department.setName(name + System.nanoTime());
+        department.setParentId(0L);
+        departmentMapper.insert(department);
+        return department;
+    }
+
+    private SysUser user(String realName, UserRole role, Long departmentId, Long superiorId) {
+        SysUser user = new SysUser();
+        user.setUsername("peer_" + System.nanoTime());
+        user.setPasswordHash(passwordEncoder.encode("Passw0rd!"));
+        user.setRealName(realName);
+        user.setRole(role);
+        user.setDepartmentId(departmentId);
+        user.setSuperiorId(superiorId);
+        user.setStatus("ACTIVE");
+        userMapper.insert(user);
+        return user;
+    }
+
+    private Map<String, Object> scoreBody(Long periodId, Long userId) {
+        if (userId == null) {
+            return Map.of("periodId", periodId, "okrScore", 50, "kpiScore", 30,
+                    "bonus", 0, "penalty", 0, "commentsJson", "{}");
+        }
+        return Map.of("periodId", periodId, "userId", userId, "okrScore", 52,
+                "kpiScore", 32, "bonus", 0, "penalty", 0, "commentsJson", "{}");
+    }
+
+    private Map<String, Object> response(int goal, int collaboration, int accountability,
+                                         String strengths, String improvement) {
+        return Map.of(
+                "goalContributionScore", goal,
+                "collaborationScore", collaboration,
+                "accountabilityScore", accountability,
+                "strengths", strengths,
+                "improvementSuggestion", improvement
+        );
+    }
+
+    private long invitationId(JsonNode summary, Long reviewerId) {
+        for (JsonNode invitation : summary.get("invitations")) {
+            if (invitation.get("reviewerId").asLong() == reviewerId) {
+                return invitation.get("invitationId").asLong();
+            }
+        }
+        throw new AssertionError("Invitation not found for reviewer " + reviewerId);
+    }
+
+    private String leaderSummaryPath(AssessmentPeriod period, SysUser subject) {
+        return "/api/peer-reviews/leader/summary?periodId=" + period.getId() + "&userId=" + subject.getId();
+    }
+
+    private boolean hasAnyField(JsonNode node, String... fields) {
+        for (String field : fields) {
+            if (!node.findValues(field).isEmpty()) return true;
+        }
+        return false;
+    }
+
+    private JsonNode postData(String token, String path, Object body) throws Exception {
+        JsonNode response = postApi(token, path, body);
+        assertEquals(200, response.get("code").asInt(), response.toString());
+        return response.get("data");
+    }
+
+    private JsonNode postApi(String token, String path, Object body) throws Exception {
+        String response = mockMvc.perform(post(path)
+                        .header("Authorization", "Bearer " + token)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(body)))
+                .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+        return objectMapper.readTree(response);
+    }
+
+    private JsonNode putData(String token, String path, Object body) throws Exception {
+        String response = mockMvc.perform(put(path)
+                        .header("Authorization", "Bearer " + token)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(body)))
+                .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+        JsonNode api = objectMapper.readTree(response);
+        assertEquals(200, api.get("code").asInt(), api.toString());
+        return api.get("data");
+    }
+
+    private JsonNode getData(String token, String path) throws Exception {
+        JsonNode response = getApi(token, path);
+        assertEquals(200, response.get("code").asInt(), response.toString());
+        return response.get("data");
+    }
+
+    private JsonNode getApi(String token, String path) throws Exception {
+        String response = mockMvc.perform(get(path).header("Authorization", "Bearer " + token))
+                .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+        return objectMapper.readTree(response);
+    }
+
+    private String token(SysUser user) {
+        return tokenProvider.generateAccessToken(user.getId(), user.getUsername(), user.getRole().name());
+    }
+}

+ 104 - 0
backend/src/test/java/com/emoon/okr/service/PeerReviewServiceTest.java

@@ -73,6 +73,8 @@ class PeerReviewServiceTest {
         selfScore.setStatus("SELF_SUBMITTED");
         lenient().when(scoreMapper.selectOne(any())).thenReturn(selfScore);
         lenient().when(requestMapper.selectOne(any())).thenReturn(null);
+        lenient().when(participantMapper.selectList(any())).thenReturn(List.of(
+                participant(3L, 10L, null, null, "ACTIVE")));
     }
 
     @Test
@@ -151,6 +153,10 @@ class PeerReviewServiceTest {
         assertTrue(invitations.getAllValues().stream().allMatch(item -> "PROPOSED".equals(item.getStatus())));
         assertEquals(3, result.getInvitedCount());
         assertEquals(0, result.getSubmittedCount());
+        verify(publisher).publishEvent(org.mockito.ArgumentMatchers.argThat(event ->
+                event instanceof NotificationEvent notification
+                        && notification.getUserId().equals(9L)
+                        && "PEER_REVIEW_LIST_PENDING".equals(notification.getType())));
     }
 
     @Test
@@ -161,6 +167,11 @@ class PeerReviewServiceTest {
                 invitation(30L, 3L, "PROPOSED"),
                 invitation(31L, 4L, "PROPOSED"),
                 invitation(32L, 5L, "PROPOSED")));
+        when(participantMapper.selectList(any())).thenReturn(List.of(
+                participant(2L, 10L, null, 9L, "ACTIVE"),
+                participant(3L, 10L, null, null, "ACTIVE"),
+                participant(4L, 11L, null, null, "ACTIVE"),
+                participant(5L, 12L, null, null, "ACTIVE")));
 
         assertThrows(BusinessException.class, () -> service.reviewList(20L, 8L, true, ""));
         service.reviewList(20L, 9L, true, "");
@@ -172,6 +183,39 @@ class PeerReviewServiceTest {
         verify(publisher, org.mockito.Mockito.times(3)).publishEvent(any(NotificationEvent.class));
     }
 
+    @Test
+    void archivedPeriodCannotReviewReviewerList() {
+        AssessmentPeriod period = new AssessmentPeriod();
+        period.setId(1L);
+        period.setStatus(PeriodStatus.ARCHIVED);
+        when(periodMapper.selectById(1L)).thenReturn(period);
+        when(requestMapper.selectById(20L)).thenReturn(request(20L, "PENDING_APPROVAL"));
+
+        assertThrows(BusinessException.class, () -> service.reviewList(20L, 9L, true, ""));
+
+        verify(requestMapper, never()).updateById(any());
+    }
+
+    @Test
+    void approvalRevalidatesReviewerEligibility() {
+        when(requestMapper.selectById(20L)).thenReturn(request(20L, "PENDING_APPROVAL"));
+        when(invitationMapper.selectList(any())).thenReturn(List.of(
+                invitation(30L, 3L, "PROPOSED"),
+                invitation(31L, 4L, "PROPOSED"),
+                invitation(32L, 5L, "PROPOSED")));
+        when(participantMapper.selectList(any())).thenReturn(List.of(
+                participant(2L, 10L, null, 9L, "ACTIVE"),
+                participant(3L, 10L, null, null, "ACTIVE"),
+                participant(4L, 11L, null, null, "EXEMPT"),
+                participant(5L, 12L, null, null, "ACTIVE")));
+
+        BusinessException error = assertThrows(BusinessException.class,
+                () -> service.reviewList(20L, 9L, true, ""));
+
+        assertTrue(error.getMessage().contains("有效参与人"));
+        verify(requestMapper, never()).updateById(any());
+    }
+
     @Test
     void rejectReviewerListRequiresReason() {
         PeerReviewRequest request = request(20L, "PENDING_APPROVAL");
@@ -192,6 +236,7 @@ class PeerReviewServiceTest {
     @Test
     void acceptInvitationRequiresInvitedReviewer() {
         when(invitationMapper.selectById(30L)).thenReturn(invitation(30L, 3L, "PENDING"));
+        when(requestMapper.selectById(20L)).thenReturn(request(20L, "IN_PROGRESS"));
 
         assertThrows(BusinessException.class, () -> service.acceptInvitation(30L, 4L));
         PeerReviewDto.InvitationSummary result = service.acceptInvitation(30L, 3L);
@@ -216,6 +261,35 @@ class PeerReviewServiceTest {
                 event instanceof NotificationEvent notification && notification.getUserId().equals(2L)));
     }
 
+    @Test
+    void invitationResponseIsRejectedAfterReviewDeadline() {
+        AssessmentPeriod period = new AssessmentPeriod();
+        period.setId(1L);
+        period.setStatus(PeriodStatus.ASSESSING);
+        period.setPeerReviewDeadline(LocalDateTime.now().minusMinutes(1));
+        when(periodMapper.selectById(1L)).thenReturn(period);
+        when(invitationMapper.selectById(30L)).thenReturn(invitation(30L, 3L, "PENDING"));
+        when(requestMapper.selectById(20L)).thenReturn(request(20L, "IN_PROGRESS"));
+
+        assertThrows(BusinessException.class, () -> service.acceptInvitation(30L, 3L));
+
+        verify(invitationMapper, never()).updateById(any());
+    }
+
+    @Test
+    void inactiveReviewerInvitationIsInvalidated() {
+        when(participantMapper.selectList(any())).thenReturn(List.of(
+                participant(3L, 10L, null, null, "EXEMPT")));
+        when(invitationMapper.selectById(30L)).thenReturn(invitation(30L, 3L, "PENDING"));
+        when(requestMapper.selectById(20L)).thenReturn(request(20L, "IN_PROGRESS"));
+
+        PeerReviewDto.InvitationSummary result = service.acceptInvitation(30L, 3L);
+
+        assertEquals("INVALIDATED", result.getStatus());
+        verify(invitationMapper).updateById(org.mockito.ArgumentMatchers.argThat(item ->
+                "INVALIDATED".equals(item.getStatus())));
+    }
+
     @Test
     void supplementReviewerAfterDeclineReopensLeaderApproval() {
         PeerReviewRequest request = request(20L, "IN_PROGRESS");
@@ -370,6 +444,36 @@ class PeerReviewServiceTest {
                 "SUBMITTED".equals(item.getStatus()) && item.getSubmittedAt() != null));
     }
 
+    @Test
+    void secondSubmittedResponseNotifiesFrozenLeader() {
+        PeerReviewInvitation first = invitation(31L, 4L, "SUBMITTED");
+        PeerReviewInvitation second = invitation(30L, 3L, "ACCEPTED");
+        when(invitationMapper.selectById(30L)).thenReturn(second);
+        when(invitationMapper.selectList(any())).thenReturn(List.of(first, second));
+        when(requestMapper.selectById(20L)).thenReturn(request(20L, "IN_PROGRESS"));
+
+        service.saveResponse(30L, 3L, validResponse(), true);
+
+        verify(publisher).publishEvent(org.mockito.ArgumentMatchers.argThat(event ->
+                event instanceof NotificationEvent notification
+                        && notification.getUserId().equals(9L)
+                        && "PEER_REVIEW_READY".equals(notification.getType())));
+    }
+
+    @Test
+    void secondResponseDoesNotNotifyLeaderWhileAnotherEffectiveInvitationIsOutstanding() {
+        PeerReviewInvitation first = invitation(31L, 4L, "SUBMITTED");
+        PeerReviewInvitation second = invitation(30L, 3L, "ACCEPTED");
+        PeerReviewInvitation outstanding = invitation(32L, 5L, "PENDING");
+        when(invitationMapper.selectById(30L)).thenReturn(second);
+        when(invitationMapper.selectList(any())).thenReturn(List.of(first, second, outstanding));
+        when(requestMapper.selectById(20L)).thenReturn(request(20L, "IN_PROGRESS"));
+
+        service.saveResponse(30L, 3L, validResponse(), true);
+
+        verifyNoInteractions(publisher);
+    }
+
     @Test
     void finalizationRequiresAtLeastTwoSubmittedResponses() {
         when(requestMapper.selectOne(any())).thenReturn(request(20L, "IN_PROGRESS"));