Pārlūkot izejas kodu

docs(环评): 添加 360 环评实现计划

wangkangyjy 2 dienas atpakaļ
vecāks
revīzija
6f04219752
1 mainītis faili ar 967 papildinājumiem un 0 dzēšanām
  1. 967 0
      docs/superpowers/plans/2026-07-16-360-peer-review.md

+ 967 - 0
docs/superpowers/plans/2026-07-16-360-peer-review.md

@@ -0,0 +1,967 @@
+# 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/enums/PeerReviewRequestStatus.java`:请求流程状态。
+- `backend/src/main/java/com/emoon/okr/enums/PeerReviewInvitationStatus.java`:邀请接受、拒绝、失效和提交状态。
+- `backend/src/main/java/com/emoon/okr/enums/PeerReviewResponseStatus.java`:草稿与正式提交状态。
+- `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/entity/PeerReviewResponse.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/mapper/PeerReviewResponseMapper.java`:响应持久化。
+- `backend/src/main/java/com/emoon/okr/dto/PeerReviewDto.java`:员工、评价人、Leader、管理员的最小响应 DTO。
+- `backend/src/main/java/com/emoon/okr/dto/request/SubmitPeerReviewListRequest.java`:员工提交候选人 ID。
+- `backend/src/main/java/com/emoon/okr/dto/request/ReviewPeerReviewListRequest.java`:Leader 批准或驳回整份名单。
+- `backend/src/main/java/com/emoon/okr/dto/request/DeclinePeerReviewInvitationRequest.java`:拒绝原因。
+- `backend/src/main/java/com/emoon/okr/dto/request/SavePeerReviewResponseRequest.java`:草稿/提交字段。
+- `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.js`:`peerReviewApi`。
+- `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` 增加:
+
+```java
+@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");
+    assertTableExists(jdbcTemplate, "peer_review_response");
+    assertEquals(1, jdbcTemplate.queryForObject(
+            "SELECT COUNT(*) FROM schema_migration WHERE version = 13", Integer.class));
+}
+```
+
+- [ ] **步骤 2:运行测试并确认失败**
+
+运行:
+
+```bash
+cd backend
+mvn -Dtest=DatabaseMigrationRunnerTest#migrationAddsPeerReviewTablesAndDeadlines test
+```
+
+预期:FAIL,提示 `peer_invitation_deadline` 或 `peer_review_request` 不存在。
+
+- [ ] **步骤 3:实现 schema 与迁移**
+
+在 `schema.sql` 和 migration v13 中使用相同定义:
+
+在 `schema.sql` 的 `assessment_period` 表中,将以下两列放在 `confirmation_due_date` 之后;旧库由 v13 的 `addColumnIfMissing` 添加:
+
+```sql
+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,
+    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);
+
+CREATE TABLE IF NOT EXISTS peer_review_response (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    invitation_id INTEGER NOT NULL,
+    goal_contribution_score INTEGER,
+    collaboration_score INTEGER,
+    accountability_score INTEGER,
+    strengths TEXT,
+    improvement_suggestion TEXT,
+    status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
+    submitted_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_response_invitation
+    ON peer_review_response(invitation_id);
+```
+
+迁移必须分别调用 `context.addColumnIfMissing("assessment_period", "peer_invitation_deadline", "TEXT")` 和 `context.addColumnIfMissing("assessment_period", "peer_review_deadline", "TEXT")`,再用 `context.execute` 建表和索引,保证旧数据库可升级。
+
+- [ ] **步骤 4:创建类型安全的 entity 与 mapper**
+
+枚举值固定为:
+
+```java
+public enum PeerReviewRequestStatus { DRAFT, PENDING_APPROVAL, REJECTED, IN_PROGRESS, READY_FOR_FINAL, ARCHIVED }
+public enum PeerReviewInvitationStatus { PROPOSED, PENDING, ACCEPTED, DECLINED, SUBMITTED, INVALIDATED }
+public enum PeerReviewResponseStatus { DRAFT, SUBMITTED }
+```
+
+三个实体使用 `@TableName`、`@TableId(type = IdType.AUTO)`、`@TableLogic` 和现有时间自动填充模式;`AssessmentPeriod` 增加:
+
+```java
+private LocalDateTime peerInvitationDeadline;
+private LocalDateTime peerReviewDeadline;
+```
+
+- [ ] **步骤 5:运行迁移测试和完整后端测试**
+
+运行:
+
+```bash
+cd backend
+mvn -Dtest=DatabaseMigrationRunnerTest test
+mvn test
+```
+
+预期:迁移测试通过;后端 113 个基线测试加新增测试全部通过。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+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/enums/PeerReviewRequestStatus.java backend/src/main/java/com/emoon/okr/enums/PeerReviewInvitationStatus.java backend/src/main/java/com/emoon/okr/enums/PeerReviewResponseStatus.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/entity/PeerReviewResponse.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/mapper/PeerReviewResponseMapper.java
+git commit -m "feat(环评): 添加 360 环评持久化模型"
+```
+
+## 任务 2:候选人资格与员工邀请名单
+
+**文件:**
+- 创建:`backend/src/main/java/com/emoon/okr/dto/PeerReviewDto.java`
+- 创建:`backend/src/main/java/com/emoon/okr/dto/request/SubmitPeerReviewListRequest.java`
+- 创建:`backend/src/main/java/com/emoon/okr/service/PeerReviewService.java`
+- 创建:`backend/src/test/java/com/emoon/okr/service/PeerReviewServiceTest.java`
+
+- [ ] **步骤 1:编写名单规则失败测试**
+
+测试至少覆盖以下方法:
+
+```java
+@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:运行测试并确认失败**
+
+运行:
+
+```bash
+cd backend
+mvn -Dtest=PeerReviewServiceTest test
+```
+
+预期:FAIL,提示 `PeerReviewService` 不存在。
+
+- [ ] **步骤 3:定义最小 DTO 与请求**
+
+`SubmitPeerReviewListRequest`:
+
+```java
+@Data
+public class SubmitPeerReviewListRequest {
+    @NotNull
+    private Long periodId;
+
+    @NotEmpty
+    @Size(min = 3, max = 5)
+    private List<Long> reviewerIds;
+}
+```
+
+`PeerReviewDto` 至少定义 `Candidate`、`EmployeeProgress`、`InvitationSummary`、`LeaderSummary`、`AdminStatistics` 五个嵌套 DTO;员工 DTO 不得声明评分、评论或单人提交时间字段。
+
+- [ ] **步骤 4:实现候选人与名单校验**
+
+`PeerReviewService` 先实现以下公共边界:
+
+```java
+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.evaluatorId` 为 `leaderId`,邀请只保存周期快照部门关系。
+
+- [ ] **步骤 5:运行单元测试**
+
+```bash
+cd backend
+mvn -Dtest=PeerReviewServiceTest test
+```
+
+预期:名单边界、部门覆盖和上下级排除测试通过。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add backend/src/main/java/com/emoon/okr/dto/PeerReviewDto.java backend/src/main/java/com/emoon/okr/dto/request/SubmitPeerReviewListRequest.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/dto/request/ReviewPeerReviewListRequest.java`
+- 创建:`backend/src/main/java/com/emoon/okr/dto/request/DeclinePeerReviewInvitationRequest.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`
+
+- [ ] **步骤 1:编写审核和拒绝失败测试**
+
+```java
+@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 ->
+            invitation.getStatus() == PeerReviewInvitationStatus.PENDING));
+}
+
+@Test
+void declineRequiresReasonAndLeavesReviewerIdentityVisibleOnlyToSubjectAndLeader() {
+    givenPendingInvitation(30L, 20L, 3L);
+
+    assertThrows(BusinessException.class, () -> service.declineInvitation(30L, 3L, " "));
+    service.declineInvitation(30L, 3L, "合作接触不足");
+
+    verify(invitationMapper).updateById(argThat(invitation ->
+            invitation.getStatus() == PeerReviewInvitationStatus.DECLINED
+                    && "合作接触不足".equals(invitation.getDeclineReason())));
+}
+```
+
+- [ ] **步骤 2:运行测试并确认失败**
+
+```bash
+cd backend
+mvn -Dtest=PeerReviewServiceTest test
+```
+
+预期:FAIL,提示 `reviewList`、`acceptInvitation` 或 `declineInvitation` 未定义。
+
+- [ ] **步骤 3:实现状态转换与通知事件**
+
+增加服务方法:
+
+```java
+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**
+
+```java
+@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 ReviewPeerReviewListRequest request) {
+        return ApiResult.success(peerReviewService.reviewList(
+                id, SecurityUtils.getCurrentUserId(), request.getApproved(), request.getReason()));
+    }
+}
+```
+
+同一 Controller 增加 `/invitations/{id}/accept` 和 `/invitations/{id}/decline`,actor 始终来自 `SecurityUtils`。
+
+- [ ] **步骤 5:运行单元测试与 Controller 编译**
+
+```bash
+cd backend
+mvn -Dtest=PeerReviewServiceTest test
+mvn -DskipTests package
+```
+
+预期:测试通过,Spring Controller 编译成功。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add backend/src/main/java/com/emoon/okr/dto/request/ReviewPeerReviewListRequest.java backend/src/main/java/com/emoon/okr/dto/request/DeclinePeerReviewInvitationRequest.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/request/SavePeerReviewResponseRequest.java`
+- 修改:`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:编写材料隐私与提交失败测试**
+
+```java
+@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);
+    SavePeerReviewResponseRequest 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:运行测试并确认失败**
+
+```bash
+cd backend
+mvn -Dtest=PeerReviewServiceTest test
+```
+
+预期:FAIL,提示 `ReviewerWorkspace` 或 `saveResponse` 不存在。
+
+- [ ] **步骤 3:实现请求验证和最小 Reviewer DTO**
+
+`SavePeerReviewResponseRequest`:
+
+```java
+@Data
+public class SavePeerReviewResponseRequest {
+    @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:实现限定材料与响应写入**
+
+增加:
+
+```java
+public PeerReviewDto.ReviewerWorkspace getReviewerWorkspace(Long invitationId, Long reviewerId)
+public PeerReviewDto.InvitationSummary saveResponse(Long invitationId, Long reviewerId,
+        SavePeerReviewResponseRequest body, boolean submit)
+```
+
+材料 DTO 仅复制 `OkrDetailDto` 的目标、KR 标题、当前值、目标值、状态和进度,以及 SELF `PerformanceScoreItem` 的 `name`、`description`;禁止复用 `ScoringDto` 或实体作为响应。正式提交后 `response.status=SUBMITTED`、`invitation.status=SUBMITTED`,再次提交返回业务错误。
+
+- [ ] **步骤 5:运行测试**
+
+```bash
+cd backend
+mvn -Dtest=PeerReviewServiceTest test
+```
+
+预期:限定材料、草稿不计数、正式提交不可修改和输入边界测试通过。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add backend/src/main/java/com/emoon/okr/dto/request/SavePeerReviewResponseRequest.java 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` 并增加:
+
+```java
+@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:运行测试并确认失败**
+
+```bash
+cd backend
+mvn -Dtest=PerformanceServiceTest#submitSuperiorScoreRejectsUntilPeerReviewCanFinalize test
+```
+
+预期:FAIL,构造器或 `requireCanFinalize` 尚不存在。
+
+- [ ] **步骤 3:实现准确的开放条件**
+
+`PeerReviewService` 增加:
+
+```java
+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` 前调用:
+
+```java
+peerReviewService.requireCanFinalize(periodId, userId, evaluatorId);
+```
+
+不修改 `calculateTotal`、`PerformanceScore` 或 `commentsJson`,确保环评不进入 100 分公式。
+
+- [ ] **步骤 5:运行测试**
+
+```bash
+cd backend
+mvn -Dtest=PeerReviewServiceTest,PerformanceServiceTest test
+```
+
+预期:少于两份被阻止;截止前全部完成可提前终评;截止后两份即可;正式总分不变。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+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:编写截止时间、统计隐私和去重测试**
+
+```java
+@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:运行测试并确认失败**
+
+```bash
+cd backend
+mvn -Dtest=PeerReviewServiceTest,AssessmentPeriodServiceTest,NotificationEventListenerTest test
+```
+
+预期:至少一个测试因统计、期限校验或通知去重未实现而失败。
+
+- [ ] **步骤 3:实现周期截止校验和匿名统计**
+
+`AssessmentPeriodService.create/update` 校验两个字段非空、名单截止早于评价截止、均位于 `ASSESSING` 可用区间;已经开始的环评只允许延长,不允许缩短到当前时间之前。`PeerReviewController` 增加 HR_ADMIN 才可访问的 `/admin/statistics?periodId=`,响应只包含计数和阻塞员工,不包含评价人或内容。
+
+- [ ] **步骤 4:实现通知去重与 24 小时提醒**
+
+`NotificationService` 增加:
+
+```java
+@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_DUE` 或 `PEER_REVIEW_DUE`;用请求/邀请 ID 作为引用保证去重。
+
+- [ ] **步骤 5:运行后端完整测试**
+
+```bash
+cd backend
+mvn test
+```
+
+预期:全部测试通过,日志中无重复提醒插入失败。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+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:编写前端契约失败测试**
+
+```javascript
+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:运行测试并确认失败**
+
+```bash
+cd frontend
+npm test -- peer-review-contract.test.js
+```
+
+预期:FAIL,页面尚未包含邀请卡与 API。
+
+- [ ] **步骤 3:增加 `peerReviewApi`**
+
+```javascript
+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:运行前端测试和构建**
+
+```bash
+cd frontend
+npm test -- peer-review-contract.test.js
+npm run build
+```
+
+预期:契约测试通过,Vite 构建成功。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+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:扩展失败契约测试**
+
+```javascript
+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:运行测试并确认失败**
+
+```bash
+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_INVITED`、`PEER_REVIEW_LIST_DUE`、`PEER_REVIEW_DUE`、`PEER_REVIEW_REJECTED`、`PEER_REVIEW_READY`。当 `refType === 'peer_review_invitation'` 时跳转:
+
+```javascript
+router.push({ name: 'PeerReviewInvitation', params: { invitationId: notification.refId } })
+```
+
+通知列表行绑定点击,并先标记已读;顶部弹层复用同样规则。
+
+- [ ] **步骤 5:运行测试和构建**
+
+```bash
+cd frontend
+npm test
+npm run build
+```
+
+预期:全部前端测试和构建通过。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+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 失败契约测试**
+
+```javascript
+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:运行测试并确认失败**
+
+```bash
+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:运行测试和构建**
+
+```bash
+cd frontend
+npm test
+npm run build
+```
+
+预期:全部前端测试和构建通过。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+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、同部门同事、两名跨部门同事、直属下属、超级管理员。单个测试按顺序执行:
+
+```java
+@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:运行集成测试并修复只与环评有关的失败**
+
+```bash
+cd backend
+mvn -Dtest=PeerReviewLifecycleApiIntegrationTest test
+```
+
+预期:完整生命周期通过;员工、无关评价人、实时新 Leader 和管理员均无法读取原始反馈。
+
+- [ ] **步骤 3:运行后端完整测试**
+
+```bash
+cd backend
+mvn test
+```
+
+预期:所有测试通过,Failures 0、Errors 0。
+
+- [ ] **步骤 4:运行前端完整测试和构建**
+
+```bash
+cd frontend
+npm test
+npm run build
+```
+
+预期:所有 Vitest 文件通过,Vite 构建成功;允许保留现有大 chunk warning,不得新增编译错误。
+
+- [ ] **步骤 5:运行仓库级差异检查**
+
+```bash
+git diff --check
+git status --short
+git log --oneline --decorate -12
+```
+
+预期:`git diff --check` 无输出;工作区只包含计划内变更;提交历史按 10 个垂直任务组织。
+
+- [ ] **步骤 6:Commit 集成测试与最终修正**
+
+```bash
+git add backend/src/test/java/com/emoon/okr/integration/PeerReviewLifecycleApiIntegrationTest.java backend/src frontend/src
+git commit -m "test(环评): 覆盖 360 环评完整生命周期"
+```
+
+- [ ] **步骤 7:重新运行最终验证**
+
+```bash
+cd backend && mvn test
+cd ../frontend && npm test && npm run build
+cd .. && git status --short
+```
+
+预期:后端、前端测试和构建全部退出码 0;工作区干净。