面向 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.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 跳转。文件:
backend/src/main/resources/schema.sqlbackend/src/main/java/com/emoon/okr/config/DatabaseMigrationRunner.java:394backend/src/test/java/com/emoon/okr/config/DatabaseMigrationRunnerTest.javabackend/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));
}
运行:
cd backend
mvn -Dtest=DatabaseMigrationRunnerTest#migrationAddsPeerReviewTablesAndDeadlines test
预期:FAIL,提示 peer_invitation_deadline 或 peer_review_request 不存在。
在 schema.sql 和 migration v13 中使用相同定义:
在 schema.sql 的 assessment_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 建表和索引,保证旧数据库可升级。
状态沿用现有服务字符串常量,不新增 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;
运行:
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 环评持久化模型"
文件:
backend/src/main/java/com/emoon/okr/dto/PeerReviewDto.javabackend/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 或直属下属"));
}
运行:
cd backend
mvn -Dtest=PeerReviewServiceTest test
预期:FAIL,提示 PeerReviewService 不存在。
PeerReviewDto.SubmitListRequest:
@Data
public static class SubmitListRequest {
@NotNull
private Long periodId;
@NotEmpty
@Size(min = 3, max = 5)
private List<Long> reviewerIds;
}
PeerReviewDto 至少定义 Candidate、EmployeeProgress、InvitationSummary、LeaderSummary、AdminStatistics 五个嵌套 DTO;员工 DTO 不得声明评分、评论或单人提交时间字段。
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.evaluatorId 为 leaderId,邀请只保存周期快照部门关系。
[ ] 步骤 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(环评): 实现评价人资格与邀请名单"
文件:
backend/src/main/java/com/emoon/okr/controller/PeerReviewController.javabackend/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,提示 reviewList、acceptInvitation 或 declineInvitation 未定义。
增加服务方法:
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(环评): 实现名单审核与邀请响应"
文件:
backend/src/main/java/com/emoon/okr/dto/PeerReviewDto.javabackend/src/main/java/com/emoon/okr/service/PeerReviewService.javabackend/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,提示 ReviewerWorkspace 或 saveResponse 不存在。
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 字。
增加:
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 PerformanceScoreItem 的 name、description;禁止复用 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(环评): 实现同事评价与隐私材料"
文件:
backend/src/main/java/com/emoon/okr/service/PeerReviewService.javabackend/src/main/java/com/emoon/okr/controller/PeerReviewController.javabackend/src/main/java/com/emoon/okr/service/PerformanceService.java:87backend/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 尚不存在。
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 不调用该映射函数。
在 PerformanceService.submitSuperiorScore 完成周期参与人和 SELF 评分校验后、写入 SUPERIOR 前调用:
peerReviewService.requireCanFinalize(periodId, userId, evaluatorId);
不修改 calculateTotal、PerformanceScore 或 commentsJson,确保环评不进入 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 终评门禁"
文件:
backend/src/main/java/com/emoon/okr/service/AssessmentPeriodService.javabackend/src/main/java/com/emoon/okr/service/PeerReviewService.javabackend/src/main/java/com/emoon/okr/controller/PeerReviewController.javabackend/src/main/java/com/emoon/okr/service/NotificationService.javabackend/src/main/java/com/emoon/okr/event/NotificationEventListener.javabackend/src/main/java/com/emoon/okr/schedule/PeerReviewReminderScheduler.javabackend/src/test/java/com/emoon/okr/service/AssessmentPeriodServiceTest.javabackend/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
预期:至少一个测试因统计、期限校验或通知去重未实现而失败。
AssessmentPeriodService.create/update 校验两个字段非空、名单截止早于评价截止、均位于 ASSESSING 可用区间;已经开始的环评只允许延长,不允许缩短到当前时间之前。PeerReviewController 增加 HR_ADMIN 才可访问的 /admin/statistics?periodId=,响应只包含计数和阻塞员工,不包含评价人或内容。
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_DUE 或 PEER_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(环评): 添加截止统计与提醒"
文件:
frontend/src/api/index.jsfrontend/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(环评): 添加员工邀请与匿名进度"
文件:
frontend/src/views/scoring/PeerReviewInvitationView.vuefrontend/src/router/index.jsfrontend/src/views/notification/NotificationView.vuefrontend/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,路由和页面不存在。
页面加载 peerReviewApi.getWorkspace(route.params.invitationId),展示只读 OKR/KR 和工作成果;提供接受/拒绝、三个 1~5 分、两个 10~300 字文本、保存草稿和正式提交。正式提交前前端做相同校验,但以后端为准;提交成功后页面锁定。
在通知类型映射增加 PEER_REVIEW_INVITED、PEER_REVIEW_LIST_DUE、PEER_REVIEW_DUE、PEER_REVIEW_REJECTED、PEER_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(环评): 添加同事评价页面与通知跳转"
文件:
frontend/src/views/scoring/ScoringListView.vuefrontend/src/views/scoring/ScoringEditView.vuefrontend/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 环评区域和管理员字段尚不存在。
ScoringListView 合并 getLeaderQueue(periodId),显示“待审核名单”“环评 N/2”“可终评”。ScoringEditView 在正式评分表上方展示名单审核区或实名原始评价卡、三维均分和“只作参考、不计入总分”提示;后端 canFinalize=false 时禁用提交按钮并显示具体门禁原因。
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 与管理员工作台"
文件:
创建: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;工作区干净。