2026-07-16-360-peer-review.md 39 KB

360 环评实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 在现有 ASSESSING 绩效流程中加入员工邀请、Leader 审核、同事评价和两份有效反馈门禁,同时保证原始环评内容只对周期快照 Leader 可见。

架构: 新建独立 PeerReviewService 深模块管理请求、邀请/评价、角色 DTO 和门禁;现有 PerformanceService 只调用 requireCanFinalize(periodId, userId, leaderId)。评价草稿与正式内容直接保存在邀请表,沿用项目字符串状态模式;前端复用“我的绩效”“绩效考评”“周期管理”和通知中心,仅新增评价人填写路由。

技术栈: Java 17、Spring Boot 3.2、MyBatis-Plus、SQLite、JUnit 5、Mockito、Vue 3、Element Plus、Vite、Vitest。


文件结构

后端新增文件

  • backend/src/main/java/com/emoon/okr/entity/PeerReviewRequest.java:每位员工每周期唯一的 360 请求。
  • backend/src/main/java/com/emoon/okr/entity/PeerReviewInvitation.java:累计邀请、拒绝原因、评价草稿和正式内容。
  • backend/src/main/java/com/emoon/okr/mapper/PeerReviewRequestMapper.java:请求持久化。
  • backend/src/main/java/com/emoon/okr/mapper/PeerReviewInvitationMapper.java:邀请与评价持久化。
  • backend/src/main/java/com/emoon/okr/dto/PeerReviewDto.java:四类角色的最小响应 DTO 和四个嵌套请求类型。
  • backend/src/main/java/com/emoon/okr/controller/PeerReviewController.java:四类角色 API。
  • backend/src/main/java/com/emoon/okr/service/PeerReviewService.java:资格、状态、隐私、聚合与终评门禁。
  • backend/src/main/java/com/emoon/okr/schedule/PeerReviewReminderScheduler.java:截止前 24 小时去重提醒。
  • backend/src/test/java/com/emoon/okr/service/PeerReviewServiceTest.java:领域规则与隐私单元测试。
  • backend/src/test/java/com/emoon/okr/integration/PeerReviewLifecycleApiIntegrationTest.java:完整 API 生命周期与权限矩阵。

前端新增文件

  • frontend/src/views/scoring/PeerReviewInvitationView.vue:评价人限定材料与评价表。
  • frontend/src/__tests__/peer-review-contract.test.js:路由、API、页面隐私和门禁契约。

修改文件

  • backend/src/main/resources/schema.sql:最新安装的表、索引和周期截止字段。
  • backend/src/main/java/com/emoon/okr/config/DatabaseMigrationRunner.java:增加 v13 升级迁移。
  • backend/src/test/java/com/emoon/okr/config/DatabaseMigrationRunnerTest.java:验证 v13 幂等迁移。
  • backend/src/main/java/com/emoon/okr/entity/AssessmentPeriod.java:两个 LocalDateTime 截止字段。
  • backend/src/main/java/com/emoon/okr/service/AssessmentPeriodService.java:截止时间校验和归档只读约束。
  • backend/src/main/java/com/emoon/okr/service/PerformanceService.java:Leader 提交评分前调用环评门禁。
  • backend/src/test/java/com/emoon/okr/service/PerformanceServiceTest.java:门禁回归测试。
  • backend/src/main/java/com/emoon/okr/service/NotificationService.java:增加按用户、类型、引用去重的通知写入。
  • backend/src/main/java/com/emoon/okr/event/NotificationEventListener.java:使用去重通知服务。
  • backend/src/test/java/com/emoon/okr/event/NotificationEventListenerTest.java:通知去重行为。
  • frontend/src/api/index.jspeerReviewApi
  • frontend/src/router/index.js:评价邀请路由。
  • frontend/src/views/scoring/MyResultView.vue:员工邀请卡与匿名进度。
  • frontend/src/views/scoring/ScoringListView.vue:Leader 的待审核/环评进度/可终评状态。
  • frontend/src/views/scoring/ScoringEditView.vue:名单审核、实名原文与聚合参考区。
  • frontend/src/views/period/PeriodListView.vue:双截止时间和管理员统计。
  • frontend/src/views/notification/NotificationView.vue:环评通知类型和点击跳转。
  • frontend/src/components/MainLayout.vue:顶部通知中的 peer_review_invitation 跳转。

任务 1:持久化模型与 v13 迁移

文件:

  • 修改:backend/src/main/resources/schema.sql
  • 修改:backend/src/main/java/com/emoon/okr/config/DatabaseMigrationRunner.java:394
  • 修改:backend/src/test/java/com/emoon/okr/config/DatabaseMigrationRunnerTest.java
  • 修改:backend/src/main/java/com/emoon/okr/entity/AssessmentPeriod.java
  • 创建:三个 Peer Review enum、entity 和 mapper 文件

  • [ ] 步骤 1:编写失败的迁移测试

DatabaseMigrationRunnerTest 增加:

@Test
void migrationAddsPeerReviewTablesAndDeadlines() {
    createBaseTables();

    DatabaseMigrationRunner runner = new DatabaseMigrationRunner(jdbcTemplate, transactionTemplate, backupService);
    runner.run();
    runner.run();

    assertColumnExists(jdbcTemplate, "assessment_period", "peer_invitation_deadline");
    assertColumnExists(jdbcTemplate, "assessment_period", "peer_review_deadline");
    assertTableExists(jdbcTemplate, "peer_review_request");
    assertTableExists(jdbcTemplate, "peer_review_invitation");
    assertEquals(1, jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM schema_migration WHERE version = 13", Integer.class));
}
  • 步骤 2:运行测试并确认失败

运行:

cd backend
mvn -Dtest=DatabaseMigrationRunnerTest#migrationAddsPeerReviewTablesAndDeadlines test

预期:FAIL,提示 peer_invitation_deadlinepeer_review_request 不存在。

  • 步骤 3:实现 schema 与迁移

schema.sql 和 migration v13 中使用相同定义:

schema.sqlassessment_period 表中,将以下两列放在 confirmation_due_date 之后;旧库由 v13 的 addColumnIfMissing 添加:

peer_invitation_deadline TEXT,
peer_review_deadline TEXT,

CREATE TABLE IF NOT EXISTS peer_review_request (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    period_id INTEGER NOT NULL,
    subject_user_id INTEGER NOT NULL,
    leader_id INTEGER NOT NULL,
    status VARCHAR(32) NOT NULL DEFAULT 'DRAFT',
    submitted_at TEXT,
    approved_at TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    deleted INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_peer_review_request_subject
    ON peer_review_request(period_id, subject_user_id);

CREATE TABLE IF NOT EXISTS peer_review_invitation (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    request_id INTEGER NOT NULL,
    reviewer_id INTEGER NOT NULL,
    reviewer_department_id_snapshot INTEGER,
    relation_type_snapshot VARCHAR(24) NOT NULL,
    status VARCHAR(24) NOT NULL DEFAULT 'PROPOSED',
    decline_reason TEXT,
    goal_contribution_score INTEGER,
    collaboration_score INTEGER,
    accountability_score INTEGER,
    strengths TEXT,
    improvement_suggestion TEXT,
    submitted_at TEXT,
    responded_at TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    deleted INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_peer_review_invitation_reviewer
    ON peer_review_invitation(request_id, reviewer_id);
CREATE INDEX IF NOT EXISTS idx_peer_review_invitation_reviewer
    ON peer_review_invitation(reviewer_id, status);

迁移必须分别调用 context.addColumnIfMissing("assessment_period", "peer_invitation_deadline", "TEXT")context.addColumnIfMissing("assessment_period", "peer_review_deadline", "TEXT"),再用 context.execute 建表和索引,保证旧数据库可升级。

  • 步骤 4:创建最小 entity 与 mapper

状态沿用现有服务字符串常量,不新增 enum:

private static final String REQUEST_PENDING_APPROVAL = "PENDING_APPROVAL";
private static final String REQUEST_REJECTED = "REJECTED";
private static final String REQUEST_IN_PROGRESS = "IN_PROGRESS";
private static final String REQUEST_READY_FOR_FINAL = "READY_FOR_FINAL";
private static final String INVITATION_PROPOSED = "PROPOSED";
private static final String INVITATION_PENDING = "PENDING";
private static final String INVITATION_ACCEPTED = "ACCEPTED";
private static final String INVITATION_DECLINED = "DECLINED";
private static final String INVITATION_SUBMITTED = "SUBMITTED";
private static final String INVITATION_INVALIDATED = "INVALIDATED";

两个实体使用 @TableName@TableId(type = IdType.AUTO)@TableLogic 和现有时间自动填充模式;AssessmentPeriod 增加:

private LocalDateTime peerInvitationDeadline;
private LocalDateTime peerReviewDeadline;
  • 步骤 5:运行迁移测试和完整后端测试

运行:

cd backend
mvn -Dtest=DatabaseMigrationRunnerTest test
mvn test

预期:迁移测试通过;后端 113 个基线测试加新增测试全部通过。

  • [ ] 步骤 6:Commit

    git add backend/src/main/resources/schema.sql backend/src/main/java/com/emoon/okr/config/DatabaseMigrationRunner.java backend/src/test/java/com/emoon/okr/config/DatabaseMigrationRunnerTest.java backend/src/main/java/com/emoon/okr/entity/AssessmentPeriod.java backend/src/main/java/com/emoon/okr/entity/PeerReviewRequest.java backend/src/main/java/com/emoon/okr/entity/PeerReviewInvitation.java backend/src/main/java/com/emoon/okr/mapper/PeerReviewRequestMapper.java backend/src/main/java/com/emoon/okr/mapper/PeerReviewInvitationMapper.java
    git commit -m "feat(环评): 添加 360 环评持久化模型"
    

任务 2:候选人资格与员工邀请名单

文件:

  • 创建:backend/src/main/java/com/emoon/okr/dto/PeerReviewDto.java
  • 创建:backend/src/main/java/com/emoon/okr/service/PeerReviewService.java
  • 创建:backend/src/test/java/com/emoon/okr/service/PeerReviewServiceTest.java

  • [ ] 步骤 1:编写名单规则失败测试

测试至少覆盖以下方法:

@Test
void submitReviewerListRequiresThreeToFiveReviewersAndBothDepartmentRelations() {
    givenAssessingPeriod();
    givenActiveSubjectWithFrozenLeader(2L, 9L, 10L);
    givenSubmittedSelfScore(2L);
    givenCandidate(3L, 10L, null);
    givenCandidate(4L, 10L, null);
    givenCandidate(5L, 10L, null);

    BusinessException error = assertThrows(BusinessException.class,
            () -> service.submitReviewerList(1L, 2L, List.of(3L, 4L, 5L)));

    assertTrue(error.getMessage().contains("跨部门"));
    verify(requestMapper, never()).insert(any());
}

@Test
void submitReviewerListRejectsSelfFrozenLeaderAndDirectReport() {
    givenAssessingPeriod();
    givenActiveSubjectWithFrozenLeader(2L, 9L, 10L);
    givenSubmittedSelfScore(2L);
    givenCandidate(3L, 11L, 2L);

    BusinessException error = assertThrows(BusinessException.class,
            () -> service.submitReviewerList(1L, 2L, List.of(2L, 9L, 3L)));

    assertTrue(error.getMessage().contains("本人、直属 Leader 或直属下属"));
}
  • 步骤 2:运行测试并确认失败

运行:

cd backend
mvn -Dtest=PeerReviewServiceTest test

预期:FAIL,提示 PeerReviewService 不存在。

  • 步骤 3:定义最小 DTO 与请求

PeerReviewDto.SubmitListRequest

@Data
public static class SubmitListRequest {
    @NotNull
    private Long periodId;

    @NotEmpty
    @Size(min = 3, max = 5)
    private List<Long> reviewerIds;
}

PeerReviewDto 至少定义 CandidateEmployeeProgressInvitationSummaryLeaderSummaryAdminStatistics 五个嵌套 DTO;员工 DTO 不得声明评分、评论或单人提交时间字段。

  • 步骤 4:实现候选人与名单校验

PeerReviewService 先实现以下公共边界:

public List<PeerReviewDto.Candidate> listCandidates(Long periodId, Long subjectUserId)
public PeerReviewDto.EmployeeProgress submitReviewerList(Long periodId, Long subjectUserId, List<Long> reviewerIds)
public PeerReviewDto.EmployeeProgress getEmployeeProgress(Long periodId, Long subjectUserId)

submitReviewerList 必须依次校验:周期为 ASSESSING、本人为 ACTIVE、已有 SELF 评分、名单截止未过、人数 3~5、无重复、候选均为 ACTIVE、排除本人/快照 Leader/快照直属下属、同部门和跨部门各至少 1 人。请求保存 participant.evaluatorIdleaderId,邀请只保存周期快照部门关系。

  • [ ] 步骤 5:运行单元测试

    cd backend
    mvn -Dtest=PeerReviewServiceTest test
    

预期:名单边界、部门覆盖和上下级排除测试通过。

  • [ ] 步骤 6:Commit

    git add backend/src/main/java/com/emoon/okr/dto/PeerReviewDto.java backend/src/main/java/com/emoon/okr/service/PeerReviewService.java backend/src/test/java/com/emoon/okr/service/PeerReviewServiceTest.java
    git commit -m "feat(环评): 实现评价人资格与邀请名单"
    

任务 3:Leader 审核、邀请接受与拒绝补邀

文件:

  • 创建:backend/src/main/java/com/emoon/okr/controller/PeerReviewController.java
  • 修改:backend/src/main/java/com/emoon/okr/service/PeerReviewService.java
  • 修改:backend/src/test/java/com/emoon/okr/service/PeerReviewServiceTest.java

  • [ ] 步骤 1:编写审核和拒绝失败测试

    @Test
    void approveReviewerListRequiresFrozenLeaderAndCreatesPendingInvitations() {
    givenPendingRequest(20L, 1L, 2L, 9L);
    givenThreeProposedInvitations(20L, 3L, 4L, 5L);
    
    assertThrows(BusinessException.class, () -> service.reviewList(20L, 8L, true, ""));
    service.reviewList(20L, 9L, true, "");
    
    verify(invitationMapper, times(3)).updateById(argThat(invitation ->
            "PENDING".equals(invitation.getStatus())));
    }
    
    @Test
    void declineRequiresReasonAndLeavesReviewerIdentityVisibleOnlyToSubjectAndLeader() {
    givenPendingInvitation(30L, 20L, 3L);
    
    assertThrows(BusinessException.class, () -> service.declineInvitation(30L, 3L, " "));
    service.declineInvitation(30L, 3L, "合作接触不足");
    
    verify(invitationMapper).updateById(argThat(invitation ->
            "DECLINED".equals(invitation.getStatus())
                    && "合作接触不足".equals(invitation.getDeclineReason())));
    }
    
  • [ ] 步骤 2:运行测试并确认失败

    cd backend
    mvn -Dtest=PeerReviewServiceTest test
    

预期:FAIL,提示 reviewListacceptInvitationdeclineInvitation 未定义。

  • 步骤 3:实现状态转换与通知事件

增加服务方法:

public PeerReviewDto.LeaderSummary reviewList(Long requestId, Long leaderId, boolean approved, String reason)
public PeerReviewDto.InvitationSummary acceptInvitation(Long invitationId, Long reviewerId)
public PeerReviewDto.InvitationSummary declineInvitation(Long invitationId, Long reviewerId, String reason)

批准整单后请求进入 IN_PROGRESS 并给所有评价人发送 PEER_REVIEW_INVITED;驳回必须有原因,进入 REJECTED 并通知员工;拒绝邀请后通知员工补邀。所有转换使用 @Transactional 且先重新读取当前状态。

  • [ ] 步骤 4:实现 Controller 的角色最小 API

    @RestController
    @RequestMapping("/api/peer-reviews")
    @RequiredArgsConstructor
    public class PeerReviewController {
    private final PeerReviewService peerReviewService;
    
    @GetMapping("/my/candidates")
    public ApiResult<List<PeerReviewDto.Candidate>> candidates(@RequestParam Long periodId) {
        return ApiResult.success(peerReviewService.listCandidates(periodId, SecurityUtils.getCurrentUserId()));
    }
    
    @PostMapping("/my/requests")
    public ApiResult<PeerReviewDto.EmployeeProgress> submitList(@Valid @RequestBody SubmitPeerReviewListRequest request) {
        return ApiResult.success(peerReviewService.submitReviewerList(
                request.getPeriodId(), SecurityUtils.getCurrentUserId(), request.getReviewerIds()));
    }
    
    @PutMapping("/leader/requests/{id}/review")
    public ApiResult<PeerReviewDto.LeaderSummary> review(@PathVariable Long id,
            @Valid @RequestBody PeerReviewDto.ReviewListRequest request) {
        return ApiResult.success(peerReviewService.reviewList(
                id, SecurityUtils.getCurrentUserId(), request.getApproved(), request.getReason()));
    }
    }
    

同一 Controller 增加 /invitations/{id}/accept/invitations/{id}/decline,actor 始终来自 SecurityUtils

  • [ ] 步骤 5:运行单元测试与 Controller 编译

    cd backend
    mvn -Dtest=PeerReviewServiceTest test
    mvn -DskipTests package
    

预期:测试通过,Spring Controller 编译成功。

  • [ ] 步骤 6:Commit

    git add backend/src/main/java/com/emoon/okr/dto/PeerReviewDto.java backend/src/main/java/com/emoon/okr/controller/PeerReviewController.java backend/src/main/java/com/emoon/okr/service/PeerReviewService.java backend/src/test/java/com/emoon/okr/service/PeerReviewServiceTest.java
    git commit -m "feat(环评): 实现名单审核与邀请响应"
    

任务 4:限定材料、草稿与正式评价

文件:

  • 修改:backend/src/main/java/com/emoon/okr/dto/PeerReviewDto.java
  • 修改:backend/src/main/java/com/emoon/okr/service/PeerReviewService.java
  • 修改:backend/src/main/java/com/emoon/okr/controller/PeerReviewController.java
  • 修改:backend/src/test/java/com/emoon/okr/service/PeerReviewServiceTest.java

  • [ ] 步骤 1:编写材料隐私与提交失败测试

    @Test
    void reviewerMaterialContainsOkrAndDescriptionsButNoPerformanceScores() {
    givenAcceptedInvitation(30L, 20L, 3L);
    givenApprovedOkrForSubject(2L);
    givenSelfScoreItemsWithScores(2L);
    
    PeerReviewDto.ReviewerWorkspace workspace = service.getReviewerWorkspace(30L, 3L);
    
    assertFalse(workspace.getObjectives().isEmpty());
    assertFalse(workspace.getWorkResults().isEmpty());
    JsonNode json = new ObjectMapper().valueToTree(workspace);
    assertFalse(json.has("selfScore"));
    assertFalse(json.has("bonus"));
    assertFalse(json.has("penalty"));
    }
    
    @Test
    void submitResponseRequiresThreeScoresAndTwoTenCharacterComments() {
    givenAcceptedInvitation(30L, 20L, 3L);
    PeerReviewDto.SaveResponseRequest request = validResponse();
    request.setStrengths("太短");
    
    BusinessException error = assertThrows(BusinessException.class,
            () -> service.saveResponse(30L, 3L, request, true));
    
    assertTrue(error.getMessage().contains("10"));
    verify(responseMapper, never()).insert(any());
    }
    
  • [ ] 步骤 2:运行测试并确认失败

    cd backend
    mvn -Dtest=PeerReviewServiceTest test
    

预期:FAIL,提示 ReviewerWorkspacesaveResponse 不存在。

  • 步骤 3:实现请求验证和最小 Reviewer DTO

PeerReviewDto.SaveResponseRequest

@Data
public static class SaveResponseRequest {
    @Min(1) @Max(5)
    private Integer goalContributionScore;
    @Min(1) @Max(5)
    private Integer collaborationScore;
    @Min(1) @Max(5)
    private Integer accountabilityScore;
    @Size(max = 300)
    private String strengths;
    @Size(max = 300)
    private String improvementSuggestion;
}

草稿允许空字段;正式提交时服务层要求三个分数完整且两项文字 trim 后均为 10~300 字。

  • 步骤 4:实现限定材料与响应写入

增加:

public PeerReviewDto.ReviewerWorkspace getReviewerWorkspace(Long invitationId, Long reviewerId)
public PeerReviewDto.InvitationSummary saveResponse(Long invitationId, Long reviewerId,
        PeerReviewDto.SaveResponseRequest body, boolean submit)

材料 DTO 仅复制 OkrDetailDto 的目标、KR 标题、当前值、目标值、状态和进度,以及 SELF PerformanceScoreItemnamedescription;禁止复用 ScoringDto 或实体作为响应。草稿直接更新邀请表的可空评价字段;正式提交后 invitation.status=SUBMITTED 并写入 submittedAt,再次提交返回业务错误。

  • [ ] 步骤 5:运行测试

    cd backend
    mvn -Dtest=PeerReviewServiceTest test
    

预期:限定材料、草稿不计数、正式提交不可修改和输入边界测试通过。

  • [ ] 步骤 6:Commit

    git add backend/src/main/java/com/emoon/okr/dto/PeerReviewDto.java backend/src/main/java/com/emoon/okr/service/PeerReviewService.java backend/src/main/java/com/emoon/okr/controller/PeerReviewController.java backend/src/test/java/com/emoon/okr/service/PeerReviewServiceTest.java
    git commit -m "feat(环评): 实现同事评价与隐私材料"
    

任务 5:Leader 聚合结果与正式终评门禁

文件:

  • 修改:backend/src/main/java/com/emoon/okr/service/PeerReviewService.java
  • 修改:backend/src/main/java/com/emoon/okr/controller/PeerReviewController.java
  • 修改:backend/src/main/java/com/emoon/okr/service/PerformanceService.java:87
  • 修改:backend/src/test/java/com/emoon/okr/service/PerformanceServiceTest.java
  • 修改:backend/src/test/java/com/emoon/okr/service/PeerReviewServiceTest.java

  • [ ] 步骤 1:编写门禁失败测试

PerformanceServiceTest 注入 mock PeerReviewService 并增加:

@Test
void submitSuperiorScoreRejectsUntilPeerReviewCanFinalize() {
    when(periodMapper.selectById(1L)).thenReturn(period());
    when(userMapper.selectById(2L)).thenReturn(user(2L, 3L));
    when(userMapper.selectById(3L)).thenReturn(user(3L, null));
    when(participantMapper.selectOne(any())).thenReturn(activeParticipant(2L, 3L));
    when(scoreMapper.selectOne(any())).thenReturn(score(ScoreType.SELF, "SELF_SUBMITTED"));
    doThrow(new BusinessException("至少需要 2 份有效环评"))
            .when(peerReviewService).requireCanFinalize(1L, 2L, 3L);

    BusinessException error = assertThrows(BusinessException.class,
            () -> performanceService.submitSuperiorScore(1L, 2L, 3L, 50.0, 30.0, 0.0, 0.0, "{}"));

    assertTrue(error.getMessage().contains("2 份"));
    verify(scoreMapper, never()).insert(argThat(score -> score.getType() == ScoreType.SUPERIOR));
}
  • [ ] 步骤 2:运行测试并确认失败

    cd backend
    mvn -Dtest=PerformanceServiceTest#submitSuperiorScoreRejectsUntilPeerReviewCanFinalize test
    

预期:FAIL,构造器或 requireCanFinalize 尚不存在。

  • 步骤 3:实现准确的开放条件

PeerReviewService 增加:

public void requireCanFinalize(Long periodId, Long subjectUserId, Long leaderId)
public PeerReviewDto.LeaderSummary getLeaderSummary(Long periodId, Long subjectUserId, Long leaderId)
public List<PeerReviewDto.LeaderQueueItem> getLeaderQueue(Long periodId, Long leaderId)

计算规则:已提交响应不少于 2;评价截止前还要求所有未拒绝、未失效的邀请均已提交;评价截止后只要求不少于 2。Leader DTO 可包含实名、三分、原文和均分;员工与管理员 DTO 不调用该映射函数。

  • 步骤 4:在正式评分事务内接入单一门禁

PerformanceService.submitSuperiorScore 完成周期参与人和 SELF 评分校验后、写入 SUPERIOR 前调用:

peerReviewService.requireCanFinalize(periodId, userId, evaluatorId);

不修改 calculateTotalPerformanceScorecommentsJson,确保环评不进入 100 分公式。

  • [ ] 步骤 5:运行测试

    cd backend
    mvn -Dtest=PeerReviewServiceTest,PerformanceServiceTest test
    

预期:少于两份被阻止;截止前全部完成可提前终评;截止后两份即可;正式总分不变。

  • [ ] 步骤 6:Commit

    git add backend/src/main/java/com/emoon/okr/service/PeerReviewService.java backend/src/main/java/com/emoon/okr/controller/PeerReviewController.java backend/src/main/java/com/emoon/okr/service/PerformanceService.java backend/src/test/java/com/emoon/okr/service/PerformanceServiceTest.java backend/src/test/java/com/emoon/okr/service/PeerReviewServiceTest.java
    git commit -m "feat(环评): 接入 Leader 终评门禁"
    

任务 6:管理员截止时间、匿名统计与去重提醒

文件:

  • 修改:backend/src/main/java/com/emoon/okr/service/AssessmentPeriodService.java
  • 修改:backend/src/main/java/com/emoon/okr/service/PeerReviewService.java
  • 修改:backend/src/main/java/com/emoon/okr/controller/PeerReviewController.java
  • 修改:backend/src/main/java/com/emoon/okr/service/NotificationService.java
  • 修改:backend/src/main/java/com/emoon/okr/event/NotificationEventListener.java
  • 创建:backend/src/main/java/com/emoon/okr/schedule/PeerReviewReminderScheduler.java
  • 修改:backend/src/test/java/com/emoon/okr/service/AssessmentPeriodServiceTest.java
  • 修改:backend/src/test/java/com/emoon/okr/service/PeerReviewServiceTest.java
  • 修改:backend/src/test/java/com/emoon/okr/event/NotificationEventListenerTest.java

  • [ ] 步骤 1:编写截止时间、统计隐私和去重测试

    @Test
    void adminStatisticsContainCountsButNoReviewerIdentityOrContent() {
    givenPeriodWithPeerDeadlines();
    givenSubmittedResponses(2L, 3L);
    
    PeerReviewDto.AdminStatistics statistics = service.getAdminStatistics(1L, 99L);
    
    assertEquals(2, statistics.getSubmittedResponseCount());
    JsonNode json = new ObjectMapper().valueToTree(statistics);
    assertFalse(json.has("reviewerNames"));
    assertFalse(json.has("comments"));
    }
    
    @Test
    void periodRejectsInvalidPeerReviewDeadlineOrder() {
    AssessmentPeriod period = period();
    period.setPeerInvitationDeadline(LocalDateTime.of(2026, 12, 20, 18, 0));
    period.setPeerReviewDeadline(LocalDateTime.of(2026, 12, 19, 18, 0));
    
    assertThrows(BusinessException.class, () -> periodService.create(period));
    }
    
  • [ ] 步骤 2:运行测试并确认失败

    cd backend
    mvn -Dtest=PeerReviewServiceTest,AssessmentPeriodServiceTest,NotificationEventListenerTest test
    

预期:至少一个测试因统计、期限校验或通知去重未实现而失败。

  • 步骤 3:实现周期截止校验和匿名统计

AssessmentPeriodService.create/update 校验两个字段非空、名单截止早于评价截止、均位于 ASSESSING 可用区间;已经开始的环评只允许延长,不允许缩短到当前时间之前。PeerReviewController 增加 HR_ADMIN 才可访问的 /admin/statistics?periodId=,响应只包含计数和阻塞员工,不包含评价人或内容。

  • 步骤 4:实现通知去重与 24 小时提醒

NotificationService 增加:

@Transactional
public boolean createOnce(Long userId, String type, String title, String content, String refType, Long refId) {
    long existing = notificationMapper.selectCount(new LambdaQueryWrapper<Notification>()
            .eq(Notification::getUserId, userId)
            .eq(Notification::getType, type)
            .eq(Notification::getRefType, refType)
            .eq(Notification::getRefId, refId));
    if (existing > 0) return false;
    create(userId, type, title, content, refType, refId);
    return true;
}

PeerReviewReminderScheduler 每小时运行一次,只在距离截止时间 > 23h && <= 24h 的窗口发送 PEER_REVIEW_LIST_DUEPEER_REVIEW_DUE;用请求/邀请 ID 作为引用保证去重。

  • [ ] 步骤 5:运行后端完整测试

    cd backend
    mvn test
    

预期:全部测试通过,日志中无重复提醒插入失败。

  • [ ] 步骤 6:Commit

    git add backend/src/main/java/com/emoon/okr/service/AssessmentPeriodService.java backend/src/main/java/com/emoon/okr/service/PeerReviewService.java backend/src/main/java/com/emoon/okr/controller/PeerReviewController.java backend/src/main/java/com/emoon/okr/service/NotificationService.java backend/src/main/java/com/emoon/okr/event/NotificationEventListener.java backend/src/main/java/com/emoon/okr/schedule/PeerReviewReminderScheduler.java backend/src/test/java/com/emoon/okr/service/AssessmentPeriodServiceTest.java backend/src/test/java/com/emoon/okr/service/PeerReviewServiceTest.java backend/src/test/java/com/emoon/okr/event/NotificationEventListenerTest.java
    git commit -m "feat(环评): 添加截止统计与提醒"
    

任务 7:员工邀请卡与匿名进度

文件:

  • 修改:frontend/src/api/index.js
  • 修改:frontend/src/views/scoring/MyResultView.vue
  • 创建:frontend/src/__tests__/peer-review-contract.test.js

  • [ ] 步骤 1:编写前端契约失败测试

    import { describe, expect, it } from 'vitest'
    import { readFileSync } from 'node:fs'
    import { resolve } from 'node:path'
    
    const source = path => readFileSync(resolve(process.cwd(), path), 'utf8')
    
    describe('peer review contracts', () => {
    it('adds employee invitation flow without exposing response content', () => {
    const page = source('src/views/scoring/MyResultView.vue')
    expect(page).toContain('360 环评邀请')
    expect(page).toContain('peerReviewApi.getMyProgress')
    expect(page).toContain('已收到 {{ peerProgress.submittedCount }} 份')
    expect(page).not.toContain('peerProgress.responses')
    expect(page).not.toContain('peerProgress.averageScore')
    })
    })
    
  • [ ] 步骤 2:运行测试并确认失败

    cd frontend
    npm test -- peer-review-contract.test.js
    

预期:FAIL,页面尚未包含邀请卡与 API。

  • [ ] 步骤 3:增加 peerReviewApi

    export const peerReviewApi = {
    getCandidates: periodId => api.get('/peer-reviews/my/candidates', { params: { periodId } }).then(r => r.data),
    getMyProgress: periodId => api.get('/peer-reviews/my/progress', { params: { periodId } }).then(r => r.data),
    submitList: data => api.post('/peer-reviews/my/requests', data).then(r => r.data),
    accept: id => api.put(`/peer-reviews/invitations/${id}/accept`).then(r => r.data),
    decline: (id, reason) => api.put(`/peer-reviews/invitations/${id}/decline`, { reason }).then(r => r.data),
    getWorkspace: id => api.get(`/peer-reviews/invitations/${id}/workspace`).then(r => r.data),
    saveDraft: (id, data) => api.put(`/peer-reviews/invitations/${id}/response`, { ...data, submit: false }).then(r => r.data),
    submitResponse: (id, data) => api.put(`/peer-reviews/invitations/${id}/response`, { ...data, submit: true }).then(r => r.data),
    getLeaderQueue: periodId => api.get('/peer-reviews/leader/queue', { params: { periodId } }).then(r => r.data),
    getLeaderSummary: (periodId, userId) => api.get('/peer-reviews/leader/summary', { params: { periodId, userId } }).then(r => r.data),
    reviewList: (id, data) => api.put(`/peer-reviews/leader/requests/${id}/review`, data).then(r => r.data),
    getAdminStatistics: periodId => api.get('/peer-reviews/admin/statistics', { params: { periodId } }).then(r => r.data)
    }
    
  • [ ] 步骤 4:实现员工卡片

MyResultView 中仅当 data.selfScore && !data.finalScore 时展示卡片;选择器分“本部门/跨部门”,实时校验 3~5 人和两类各至少一人。提交后显示 Leader 审核状态、拒绝人及原因、汇总 submittedCount;禁止渲染任何评分、评论或逐人提交状态。

  • [ ] 步骤 5:运行前端测试和构建

    cd frontend
    npm test -- peer-review-contract.test.js
    npm run build
    

预期:契约测试通过,Vite 构建成功。

  • [ ] 步骤 6:Commit

    git add frontend/src/api/index.js frontend/src/views/scoring/MyResultView.vue frontend/src/__tests__/peer-review-contract.test.js
    git commit -m "feat(环评): 添加员工邀请与匿名进度"
    

任务 8:评价人填写页与通知跳转

文件:

  • 创建:frontend/src/views/scoring/PeerReviewInvitationView.vue
  • 修改:frontend/src/router/index.js
  • 修改:frontend/src/views/notification/NotificationView.vue
  • 修改:frontend/src/components/MainLayout.vue
  • 修改:frontend/src/__tests__/peer-review-contract.test.js

  • [ ] 步骤 1:扩展失败契约测试

    it('routes peer-review notifications to a privacy-limited workspace', () => {
    const router = source('src/router/index.js')
    const page = source('src/views/scoring/PeerReviewInvitationView.vue')
    const notifications = source('src/views/notification/NotificationView.vue')
    expect(router).toContain("path: 'peer-review/:invitationId'")
    expect(page).toContain('目标贡献')
    expect(page).toContain('协作表现')
    expect(page).toContain('责任担当')
    expect(page).toContain('突出表现')
    expect(page).toContain('改进建议')
    expect(notifications).toContain('PEER_REVIEW_INVITED')
    })
    
  • [ ] 步骤 2:运行测试并确认失败

    cd frontend
    npm test -- peer-review-contract.test.js
    

预期:FAIL,路由和页面不存在。

  • 步骤 3:实现评价页面

页面加载 peerReviewApi.getWorkspace(route.params.invitationId),展示只读 OKR/KR 和工作成果;提供接受/拒绝、三个 1~5 分、两个 10~300 字文本、保存草稿和正式提交。正式提交前前端做相同校验,但以后端为准;提交成功后页面锁定。

  • 步骤 4:实现通知跳转

在通知类型映射增加 PEER_REVIEW_INVITEDPEER_REVIEW_LIST_DUEPEER_REVIEW_DUEPEER_REVIEW_REJECTEDPEER_REVIEW_READY。当 refType === 'peer_review_invitation' 时跳转:

router.push({ name: 'PeerReviewInvitation', params: { invitationId: notification.refId } })

通知列表行绑定点击,并先标记已读;顶部弹层复用同样规则。

  • [ ] 步骤 5:运行测试和构建

    cd frontend
    npm test
    npm run build
    

预期:全部前端测试和构建通过。

  • [ ] 步骤 6:Commit

    git add frontend/src/views/scoring/PeerReviewInvitationView.vue frontend/src/router/index.js frontend/src/views/notification/NotificationView.vue frontend/src/components/MainLayout.vue frontend/src/__tests__/peer-review-contract.test.js
    git commit -m "feat(环评): 添加同事评价页面与通知跳转"
    

任务 9:Leader 工作台与管理员配置

文件:

  • 修改:frontend/src/views/scoring/ScoringListView.vue
  • 修改:frontend/src/views/scoring/ScoringEditView.vue
  • 修改:frontend/src/views/period/PeriodListView.vue
  • 修改:frontend/src/__tests__/peer-review-contract.test.js

  • [ ] 步骤 1:编写 Leader/Admin 失败契约测试

    it('shows leader raw feedback and admin aggregate-only statistics', () => {
    const scoring = source('src/views/scoring/ScoringEditView.vue')
    const periods = source('src/views/period/PeriodListView.vue')
    expect(scoring).toContain('360 环评参考')
    expect(scoring).toContain('peerReviewApi.getLeaderSummary')
    expect(scoring).toContain('至少需要 2 份有效环评')
    expect(periods).toContain('peerInvitationDeadline')
    expect(periods).toContain('peerReviewDeadline')
    expect(periods).toContain('peerReviewApi.getAdminStatistics')
    expect(periods).not.toContain('statistics.comments')
    expect(periods).not.toContain('statistics.reviewerNames')
    })
    
  • [ ] 步骤 2:运行测试并确认失败

    cd frontend
    npm test -- peer-review-contract.test.js
    

预期:FAIL,Leader 环评区域和管理员字段尚不存在。

  • 步骤 3:实现 Leader 队列和详情

ScoringListView 合并 getLeaderQueue(periodId),显示“待审核名单”“环评 N/2”“可终评”。ScoringEditView 在正式评分表上方展示名单审核区或实名原始评价卡、三维均分和“只作参考、不计入总分”提示;后端 canFinalize=false 时禁用提交按钮并显示具体门禁原因。

  • 步骤 4:实现管理员双截止和统计

PeriodListView 新建/编辑表单加入两个 type="datetime" 控件,提交 ISO 时间;校验名单截止早于评价截止。周期详情只展示邀请覆盖率、有效邀请提交率和阻塞员工数量/姓名,不展示评价人或原文。

  • [ ] 步骤 5:运行测试和构建

    cd frontend
    npm test
    npm run build
    

预期:全部前端测试和构建通过。

  • [ ] 步骤 6:Commit

    git add frontend/src/views/scoring/ScoringListView.vue frontend/src/views/scoring/ScoringEditView.vue frontend/src/views/period/PeriodListView.vue frontend/src/__tests__/peer-review-contract.test.js
    git commit -m "feat(环评): 完成 Leader 与管理员工作台"
    

任务 10:全生命周期、权限矩阵与最终验证

文件:

  • 创建:backend/src/test/java/com/emoon/okr/integration/PeerReviewLifecycleApiIntegrationTest.java

  • [ ] 步骤 1:编写完整 API 生命周期集成测试

测试使用至少 7 个账号:被评价人、周期 Leader、同部门同事、两名跨部门同事、直属下属、超级管理员。单个测试按顺序执行:

@Test
void completesPeerReviewLifecycleWithoutLeakingResponses() throws Exception {
    long periodId = createAssessingPeriodWithSnapshotsAndDeadlines();
    submitSelfScore(periodId, subjectToken);
    long requestId = submitReviewerList(periodId, subjectToken, sameDeptId, crossDeptId, decliningId);
    approveReviewerList(requestId, leaderToken);
    declineInvitation(invitationIdFor(decliningId), decliningToken, "合作接触不足");
    submitReviewerListReplacement(periodId, subjectToken, replacementId);

    submitPeerResponse(invitationIdFor(sameDeptId), sameDeptToken, 4, 5, 4,
            "跨部门项目响应及时并主动同步风险", "可以增加阶段性复盘和书面沉淀");
    assertSuperiorScoreRejected(periodId, subjectId, leaderToken, "2 份");
    submitPeerResponse(invitationIdFor(crossDeptId), crossDeptToken, 5, 4, 4,
            "目标推进稳定且交付质量符合预期", "复杂任务开始前可以更早对齐依赖");
    submitSuperiorScore(periodId, subjectId, leaderToken);

    assertEmployeeProgressHidesScoresAndComments(periodId, subjectToken);
    assertReviewerCannotReadOtherResponse(invitationIdFor(sameDeptId), crossDeptToken);
    assertAdminStatisticsHideReviewerIdentity(periodId, adminToken);
    assertLeaderSummaryContainsIdentifiedRawFeedback(periodId, subjectId, leaderToken);
}
  • [ ] 步骤 2:运行集成测试并修复只与环评有关的失败

    cd backend
    mvn -Dtest=PeerReviewLifecycleApiIntegrationTest test
    

预期:完整生命周期通过;员工、无关评价人、实时新 Leader 和管理员均无法读取原始反馈。

  • [ ] 步骤 3:运行后端完整测试

    cd backend
    mvn test
    

预期:所有测试通过,Failures 0、Errors 0。

  • [ ] 步骤 4:运行前端完整测试和构建

    cd frontend
    npm test
    npm run build
    

预期:所有 Vitest 文件通过,Vite 构建成功;允许保留现有大 chunk warning,不得新增编译错误。

  • [ ] 步骤 5:运行仓库级差异检查

    git diff --check
    git status --short
    git log --oneline --decorate -12
    

预期:git diff --check 无输出;工作区只包含计划内变更;提交历史按 10 个垂直任务组织。

  • [ ] 步骤 6:Commit 集成测试与最终修正

    git add backend/src/test/java/com/emoon/okr/integration/PeerReviewLifecycleApiIntegrationTest.java backend/src frontend/src
    git commit -m "test(环评): 覆盖 360 环评完整生命周期"
    
  • [ ] 步骤 7:重新运行最终验证

    cd backend && mvn test
    cd ../frontend && npm test && npm run build
    cd .. && git status --short
    

预期:后端、前端测试和构建全部退出码 0;工作区干净。