From 6e8e7db31e362dd30d84ebbb7ca0fc90eff76892 Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Sun, 16 Aug 2026 15:59:26 +0800 Subject: [PATCH] fix(team): make checkpoint evidence idempotent --- .../team/service/TeamDispatchService.java | 20 ++++++- .../mate/team/service/TeamTaskService.java | 52 ++++++++++++++++--- .../team/service/TeamDispatchServiceTest.java | 8 ++- .../team/service/TeamTaskServiceTest.java | 18 +++++++ 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java index a267a264..c6dd34f4 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java @@ -271,7 +271,8 @@ public class TeamDispatchService { if (terminalCheckpoint != null) { String terminalEvidence = "[checkpoint:" + terminalCheckpoint + "] acknowledged"; boolean terminalAlreadyAcknowledged = taskService.listComments(current.getId()).stream() - .anyMatch(comment -> terminalEvidence.equals(comment.getContent())); + .anyMatch(comment -> comment.getContent() != null + && comment.getContent().contains(terminalEvidence)); if (terminalAlreadyAcknowledged) { List released = taskService.completeTask(task.getId(), null, truncate(reply, MAX_RESULT_CHARS)); @@ -392,6 +393,11 @@ public class TeamDispatchService { for (TeamTaskService.Deliverable file : taskService.listDeliverables(blocker)) { section.append(" File: ").append(file.name()).append(" → ") .append(file.url()).append('\n'); + String inspectionPath = generatedFileInspectionPath(file.url()); + if (inspectionPath != null) { + section.append(" Inspect locally: ").append(inspectionPath) + .append(" (do not guess an HTTP port)\n"); + } } } if (section.isEmpty()) { @@ -402,6 +408,18 @@ public class TeamDispatchService { .append("Use team_tasks(action=\"get\", taskId=...) for any full record.\n"); } + static String generatedFileInspectionPath(String url) { + String prefix = "/api/v1/files/generated/"; + if (url == null || !url.startsWith(prefix)) { + return null; + } + String fileId = url.substring(prefix.length()); + if (!fileId.matches("[A-Za-z0-9-]+")) { + return null; + } + return "../generated-files/" + fileId; + } + /** Push a task event onto the team channel and the lead conversation's stream. */ private void broadcast(TeamTaskEntity task, String event, Map extra) { eventChannel.publishTaskEvent(task, event, extra); diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java index a8f9df9a..5397d1f0 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java @@ -391,15 +391,25 @@ public class TeamTaskService { * @return true when the comment was a blocker that failed the task */ @Transactional - public boolean addComment(Long taskId, String authorType, String authorId, - String commentType, String content) { + public synchronized boolean addComment(Long taskId, String authorType, String authorId, + String commentType, String content) { TeamTaskEntity task = requireTask(taskId); + String normalizedType = commentType == null ? COMMENT_NOTE : commentType; + String checkpointKey = checkpointEvidenceKey(content); + if (COMMENT_NOTE.equals(normalizedType) + && checkpointKey != null + && checkpointTerminalTag(task) != null + && hasCheckpointEvidence(taskId, checkpointKey)) { + log.debug("Skipped duplicate checkpoint evidence {} on team task {}", + checkpointKey, taskId); + return false; + } TeamTaskCommentEntity comment = new TeamTaskCommentEntity(); comment.setTaskId(taskId); comment.setTeamId(task.getTeamId()); comment.setAuthorType(authorType); comment.setAuthorId(authorId); - comment.setCommentType(commentType == null ? COMMENT_NOTE : commentType); + comment.setCommentType(normalizedType); comment.setContent(content); commentMapper.insert(comment); recordEvent(task.getTeamId(), taskId, @@ -424,20 +434,46 @@ public class TeamTaskService { .orderByAsc(TeamTaskCommentEntity::getCreateTime)); } - /** Persist a note once, keyed by an exact stable content value. */ + /** Persist a note once, using a semantic checkpoint key when present. */ @Transactional public synchronized boolean addCommentOnce(Long taskId, String authorType, String authorId, String commentType, String content) { - Long existing = commentMapper.selectCount(Wrappers.lambdaQuery() - .eq(TeamTaskCommentEntity::getTaskId, taskId) - .eq(TeamTaskCommentEntity::getContent, content)); - if (existing != null && existing > 0) { + String checkpointKey = checkpointEvidenceKey(content); + boolean exists = checkpointKey == null + ? hasExactComment(taskId, content) + : hasCheckpointEvidence(taskId, checkpointKey); + if (exists) { return false; } addComment(taskId, authorType, authorId, commentType, content); return true; } + private boolean hasExactComment(Long taskId, String content) { + Long existing = commentMapper.selectCount(Wrappers.lambdaQuery() + .eq(TeamTaskCommentEntity::getTaskId, taskId) + .eq(TeamTaskCommentEntity::getContent, content)); + return existing != null && existing > 0; + } + + private boolean hasCheckpointEvidence(Long taskId, String checkpointKey) { + Long existing = commentMapper.selectCount(Wrappers.lambdaQuery() + .eq(TeamTaskCommentEntity::getTaskId, taskId) + .like(TeamTaskCommentEntity::getContent, checkpointKey)); + return existing != null && existing > 0; + } + + static String checkpointEvidenceKey(String content) { + if (content == null || content.isBlank()) { + return null; + } + Matcher matcher = Pattern.compile("(?i)\\[checkpoint:(R\\d{3,})]\\s*acknowledged") + .matcher(content); + return matcher.find() + ? "[checkpoint:" + matcher.group(1).toUpperCase() + "] acknowledged" + : null; + } + // ==================== timeline events ==================== /** Timeline detail cap, matching the column width. */ diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java index 7780f580..c20b313c 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java @@ -236,7 +236,7 @@ class TeamDispatchServiceTest { TeamTaskEntity completed = task(1L, MEMBER_A); completed.setStatus(TeamTaskStatus.COMPLETED); TeamTaskCommentEntity evidence = new TeamTaskCommentEntity(); - evidence.setContent("[checkpoint:R300] acknowledged"); + evidence.setContent("运行台账终点: [checkpoint:R300] acknowledged"); when(taskService.getTask(1L)).thenReturn(running, completed); when(taskService.checkpointTerminalTag(running)).thenReturn("R300"); when(taskService.listComments(1L)).thenReturn(List.of(evidence)); @@ -390,8 +390,14 @@ class TeamDispatchServiceTest { assertTrue(section.contains("[Prerequisite results]")); assertTrue(section.contains("pricing collected: 3 competitors")); assertTrue(section.contains("prices.xlsx → /api/v1/files/generated/x")); + assertTrue(section.contains("Inspect locally: ../generated-files/x")); + assertTrue(section.contains("do not guess an HTTP port")); assertFalse(section.contains("#2"), "vanished blockers leave no trace"); + assertNull(TeamDispatchService.generatedFileInspectionPath("https://example.com/file")); + assertNull(TeamDispatchService.generatedFileInspectionPath( + "/api/v1/files/generated/../../secret")); + // No blockers → no section at all. StringBuilder plain = new StringBuilder(); service.appendPrerequisiteResults(plain, task(4L, MEMBER_A)); diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java index dbef2bd9..5eb17afe 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java @@ -435,6 +435,24 @@ class TeamTaskServiceTest { verify(commentMapper, times(1)).insert(any(TeamTaskCommentEntity.class)); } + @Test + @DisplayName("checkpoint notes are deduplicated by their embedded stable key") + void checkpointCommentIsSemanticallyIdempotent() { + TeamTaskEntity tracker = task(5L, TeamTaskStatus.IN_PROGRESS); + tracker.setSubject("R001-R010 共享跟踪检查点"); + when(taskMapper.selectById(5L)).thenReturn(tracker); + when(commentMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L); + + assertFalse(service.addComment(5L, TeamTaskService.AUTHOR_AGENT, "2", + TeamTaskService.COMMENT_NOTE, + "[运行台账] R001: [checkpoint:R001] acknowledged")); + + verify(commentMapper, never()).insert(any(TeamTaskCommentEntity.class)); + assertEquals("[checkpoint:R001] acknowledged", + TeamTaskService.checkpointEvidenceKey( + "[运行台账] [CHECKPOINT:r001] acknowledged")); + } + // ==================== circuit breaker ==================== @Test