diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9d39ab8..d560ffe 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,28 @@ # 更新日志 +## v1.2.1 + +> 2026-07-01 + +### Bug 修复 + +- **修复 `AiReplyOrchestrator.retryOrFail` 重试计数失效** — `.then()` 丢弃了更新后的记录导致 `retryCount` 始终为 0,AI 生成失败时陷入无限重试。改为 `.flatMap()` 传递更新后的记录 +- **修复误报反馈"AI 回复"被空字符串覆盖** — `.subscribe()` 在异步流程中过早触发,导致 AI 回复生成完成后被空字符串覆盖。改为在 `.doOnSuccess()` 中触发异步生成 +- **修复 `PersonaResolver` 在响应式上下文中使用 `.block()`** — 调用阻塞方法会阻塞 Reactor 线程。改为返回 `Mono` 并使用 `Flux.concatMap().next()` 替代 for 循环 +- **修复 `penalizeComment`/`penalizeReply` 缺少乐观锁重试** — 并发更新 Comment/Reply 时可能静默失败。添加 `Retry.backoff(3, 100ms)` 重试 +- **修复 `approveOriginalComment` 缺少乐观锁重试** — 同上,添加 `Retry.backoff(3, 100ms)` 重试 +- **修复误报反馈端点无法重试 `FAIL` 状态记录** — 仅接受 `FILTERED` 和 `FALSE_POSITIVE` 状态,AI 生成失败的记录无法重试。现接受 `FAIL` 状态 +- **修复 `tag-NEUTRAL` 缺少 CSS 样式** — 中性情感标签无样式显示。补充样式定义 +- **修复 `handleTriggerAiReply` 缺少加载保护** — 触发 AI 回复按钮可被重复点击导致重复提交。添加 loading 状态 +- **修复 `filterKeyword` 输入未做防抖** — 每次按键都触发搜索,性能开销大。添加 300ms 防抖 +- **修复 `performCleanup` 逻辑错误** — 清理逻辑存在判断错误 + +### 改进 + +- **优化 `extractChoice` 分类匹配优先级** — 优先匹配违规类别(advertising/abuse/sensitive/meaningless),再匹配 `normal`,避免正常评论被误判为违规类别 + +--- + ## v1.2.0 > 2026-06-25 diff --git a/gradle.properties b/gradle.properties index 89aae36..3addcde 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.2.0 +version=1.2.1 # Fix Windows Gradle Worker Daemon exit code 268435659 when running pnpm via Exec tasks org.gradle.daemon=false diff --git a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java index 5246dbb..949a3f2 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java @@ -1060,10 +1060,13 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { return client.fetch(AiCommentReply.class, name) .flatMap(record -> { - if (!"FILTERED".equals(record.getSpec().getStatus()) - && !"FALSE_POSITIVE".equals(record.getSpec().getStatus())) { + String currentStatus = record.getSpec().getStatus(); + // 允许:FILTERED(拦截误报)、FALSE_POSITIVE(已通过但可触发AI)、FAIL(AI生成失败可重试) + if (!"FILTERED".equals(currentStatus) + && !"FALSE_POSITIVE".equals(currentStatus) + && !"FAIL".equals(currentStatus)) { return ServerResponse.badRequest() - .bodyValue(Map.of("message", "仅已拦截或误报通过状态的记录可进行误报反馈")); + .bodyValue(Map.of("message", "仅已拦截、误报通过或AI生成失败的记录可进行此操作")); } String commentName = record.getSpec().getCommentId(); @@ -1092,24 +1095,26 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { .filter(e -> e instanceof OptimisticLockingFailureException)) .then()); - // 3. 异步触发 AI 回复(不阻塞 HTTP 响应) + // 3. 异步触发 AI 回复(在记录更新完成后,不阻塞 HTTP 响应) // 使用 processFalsePositive 跳过前置过滤和去重检查 - if ("aiReply".equals(action)) { - boolean isConversation = Boolean.TRUE.equals(record.getSpec().getIsAiConversation()); - String recordName = record.getMetadata().getName(); - personaResolver.getPersonaNameFromComment(commentName) - .flatMap(personaName -> - orchestrator.processFalsePositive(commentName, replyName, isConversation, personaName, recordName) - ) - .subscribe( - null, - err -> log.warn("[FalsePositive] AI reply trigger failed for {}: {}", commentName, err.getMessage()), - () -> log.info("[FalsePositive] AI reply trigger completed for {}", commentName) - ); - } + final boolean isConversation = Boolean.TRUE.equals(record.getSpec().getIsAiConversation()); + final String recordName = record.getMetadata().getName(); return approveMono .then(updateRecordMono) + .doOnSuccess(v -> { + if ("aiReply".equals(action)) { + personaResolver.getPersonaNameFromComment(commentName) + .flatMap(personaName -> + orchestrator.processFalsePositive(commentName, replyName, isConversation, personaName, recordName) + ) + .subscribe( + null, + err -> log.warn("[FalsePositive] AI reply trigger failed for {}: {}", commentName, err.getMessage()), + () -> log.info("[FalsePositive] AI reply trigger completed for {}", commentName) + ); + } + }) .then(ServerResponse.ok().bodyValue(Map.of( "message", "aiReply".equals(action) ? "已标记为误报,AI回复正在后台生成" : "已标记为误报并通过" ))); diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationDelegate.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationDelegate.java index 7831134..48d9b4b 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationDelegate.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationDelegate.java @@ -127,6 +127,8 @@ class AiFoundationDelegate { /** * 从 chat 响应文本中提取匹配的分类值。 * 优先精确匹配,其次包含匹配。 + * 包含匹配时优先匹配违规类别(广告/辱骂/敏感/无意义),最后才匹配"正常", + * 避免 AI 解释性文本中同时出现"正常"和违规词时误判为"正常"。 * 无匹配时返回空字符串(触发 defaultIfEmpty 安全拦截),避免原始文本被误判为违规类别。 */ static String extractChoice(String text, List choices) { @@ -136,10 +138,16 @@ class AiFoundationDelegate { for (String choice : choices) { if (trimmed.equals(choice)) return choice; } - // 包含匹配(响应可能包含额外文字,如"该评论属于:广告") + // 包含匹配:先匹配违规类别,最后匹配"正常" + // 避免"该评论属于广告,不是正常评论"被误匹配为"正常" for (String choice : choices) { + if ("正常".equals(choice)) continue; if (trimmed.contains(choice)) return choice; } + // 最后检查"正常" + for (String choice : choices) { + if ("正常".equals(choice) && trimmed.contains(choice)) return choice; + } // 无匹配,返回空字符串触发安全拦截 log.warn("[Delegate] No matching choice found in response: '{}', returning empty for safety", trimmed); return ""; diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java index d7c5b33..7984d8a 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java @@ -341,7 +341,7 @@ public class AiReplyOrchestrator { // Update retryCount and reset status to PENDING return updateRecordForRetry(replyRecord, newRetryCount) .delayElement(Duration.ofSeconds(delaySeconds)) - .then(retryGenerate(context, replyRecord, modelName, personaName)); + .flatMap(updated -> retryGenerate(context, updated, modelName, personaName)); } else { log.warn("[Orchestrator] Max retry count ({}) exceeded for: {}, marking as FAIL. Reason: {}", maxRetry, context.commentId(), reason); diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/CommentPreFilterService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/CommentPreFilterService.java index 7366e6a..caaa0d3 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/CommentPreFilterService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/CommentPreFilterService.java @@ -5,13 +5,16 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.jsoup.Jsoup; import org.jsoup.safety.Safelist; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Reply; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ReactiveExtensionClient; +import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Map; @@ -178,15 +181,18 @@ public class CommentPreFilterService { spec.setApproved(false); spec.setApprovedTime(null); return client.update(comment) - .doOnSuccess(c -> log.info("[PreFilter] Comment {} set to pending for violation", commentName)) - .onErrorResume(e -> { - log.warn("[PreFilter] Failed to penalize comment {}: {}", commentName, e.getMessage()); - return Mono.empty(); - }); + .doOnSuccess(c -> log.info("[PreFilter] Comment {} set to pending for violation", commentName)); } log.debug("[PreFilter] Comment {} already unapproved, skip penalize", commentName); return Mono.empty(); }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance) + .doBeforeRetry(sig -> log.debug("[PreFilter] Retrying penalizeComment {} (attempt {})", commentName, sig.totalRetries() + 1))) + .onErrorResume(e -> { + log.warn("[PreFilter] Failed to penalize comment {} after retries: {}", commentName, e.getMessage()); + return Mono.empty(); + }) .then(); } @@ -201,15 +207,18 @@ public class CommentPreFilterService { spec.setApproved(false); spec.setApprovedTime(null); return client.update(reply) - .doOnSuccess(r -> log.info("[PreFilter] Reply {} set to pending for violation", replyName)) - .onErrorResume(e -> { - log.warn("[PreFilter] Failed to penalize reply {}: {}", replyName, e.getMessage()); - return Mono.empty(); - }); + .doOnSuccess(r -> log.info("[PreFilter] Reply {} set to pending for violation", replyName)); } log.debug("[PreFilter] Reply {} already unapproved, skip penalize", replyName); return Mono.empty(); }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance) + .doBeforeRetry(sig -> log.debug("[PreFilter] Retrying penalizeReply {} (attempt {})", replyName, sig.totalRetries() + 1))) + .onErrorResume(e -> { + log.warn("[PreFilter] Failed to penalize reply {} after retries: {}", replyName, e.getMessage()); + return Mono.empty(); + }) .then(); } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/PersonaResolver.java b/src/main/java/top/nxxy335/commentaiautopilot/service/PersonaResolver.java index 6d3128a..8d134a1 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/PersonaResolver.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/PersonaResolver.java @@ -9,8 +9,11 @@ import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Tag; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ReactiveExtensionClient; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.util.List; + /** * Shared service for resolving AI persona name from a comment's associated * post/category/tag annotations. @@ -57,64 +60,80 @@ public class PersonaResolver { return Mono.just(persona); } } - // 2. Category annotations + // 2. Category annotations (check sequentially, return first match) var spec = post.getSpec(); - if (spec != null && spec.getCategories() != null) { - for (String categoryName : spec.getCategories()) { - var persona = resolveFromCategory(categoryName); - if (persona != null) return Mono.just(persona); - } - } - // 3. Tag annotations - if (spec != null && spec.getTags() != null) { - for (String tagName : spec.getTags()) { - var persona = resolveFromTag(tagName); - if (persona != null) return Mono.just(persona); - } - } - return Mono.just(""); + List categories = (spec != null && spec.getCategories() != null) + ? spec.getCategories() : List.of(); + // 3. Tag annotations (fallback if no category match) + List tags = (spec != null && spec.getTags() != null) + ? spec.getTags() : List.of(); + + return resolveFromCategories(categories) + .switchIfEmpty(resolveFromTags(tags)); }) .defaultIfEmpty(""); } - private String resolveFromCategory(String categoryName) { - // Use block() here because this is called from a Reconciler (sync context) - // For reactive context, the caller should use the reactive version - try { - return reactiveClient.fetch(Category.class, categoryName) - .mapNotNull(cat -> { - var catAnnotations = cat.getMetadata().getAnnotations(); - if (catAnnotations != null) { - String catPersona = catAnnotations.get(AI_PERSONA_ANNOTATION); - if (catPersona != null && !catPersona.isBlank()) { - return catPersona; - } - } - return null; - }) - .block(); - } catch (Exception e) { - return null; + /** + * Sequentially check category annotations, returning the first non-empty persona. + * Uses concatMap to preserve order and short-circuit on first match. + */ + private Mono resolveFromCategories(List categoryNames) { + if (categoryNames == null || categoryNames.isEmpty()) { + return Mono.empty(); } + return Flux.fromIterable(categoryNames) + .concatMap(this::resolveFromCategory) + .next(); } - private String resolveFromTag(String tagName) { - try { - return reactiveClient.fetch(Tag.class, tagName) - .mapNotNull(tag -> { - var tagAnnotations = tag.getMetadata().getAnnotations(); - if (tagAnnotations != null) { - String tagPersona = tagAnnotations.get(AI_PERSONA_ANNOTATION); - if (tagPersona != null && !tagPersona.isBlank()) { - return tagPersona; - } - } - return null; - }) - .block(); - } catch (Exception e) { - return null; + /** + * Sequentially check tag annotations, returning the first non-empty persona. + * Uses concatMap to preserve order and short-circuit on first match. + */ + private Mono resolveFromTags(List tagNames) { + if (tagNames == null || tagNames.isEmpty()) { + return Mono.empty(); } + return Flux.fromIterable(tagNames) + .concatMap(this::resolveFromTag) + .next(); + } + + private Mono resolveFromCategory(String categoryName) { + return reactiveClient.fetch(Category.class, categoryName) + .mapNotNull(cat -> { + var catAnnotations = cat.getMetadata().getAnnotations(); + if (catAnnotations != null) { + String catPersona = catAnnotations.get(AI_PERSONA_ANNOTATION); + if (catPersona != null && !catPersona.isBlank()) { + return catPersona; + } + } + return null; + }) + .onErrorResume(e -> { + log.warn("Failed to resolve persona from category {}: {}", categoryName, e.getMessage()); + return Mono.empty(); + }); + } + + private Mono resolveFromTag(String tagName) { + return reactiveClient.fetch(Tag.class, tagName) + .mapNotNull(tag -> { + var tagAnnotations = tag.getMetadata().getAnnotations(); + if (tagAnnotations != null) { + String tagPersona = tagAnnotations.get(AI_PERSONA_ANNOTATION); + if (tagPersona != null && !tagPersona.isBlank()) { + return tagPersona; + } + } + return null; + }) + .onErrorResume(e -> { + log.warn("Failed to resolve persona from tag {}: {}", tagName, e.getMessage()); + return Mono.empty(); + }); } /** diff --git a/src/main/resources/plugin.yaml b/src/main/resources/plugin.yaml index 64b7cb9..1eb3688 100644 --- a/src/main/resources/plugin.yaml +++ b/src/main/resources/plugin.yaml @@ -30,4 +30,4 @@ spec: url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE" settingName: "comment-ai-autopilot-settings" configMapName: "comment-ai-autopilot-configmap" - version: "1.2.0" + version: "1.2.1" diff --git a/ui/src/views/LogsView.vue b/ui/src/views/LogsView.vue index f0ae677..95ea520 100644 --- a/ui/src/views/LogsView.vue +++ b/ui/src/views/LogsView.vue @@ -76,7 +76,10 @@ 误报 {{ reply.spec.filterReason || '用户确认为误报' }} - + @@ -176,7 +179,7 @@