fix: 修复重试计数、误报反馈、响应式阻塞等问题,版本号更新为v1.2.1

修复 AiReplyOrchestrator.retryOrFail 使用 .then() 导致 retryCount 失效;修复误报反馈端点 .subscribe() 过早触发覆盖 AI 回复;修复 PersonaResolver 在响应式上下文使用 .block() 阻塞线程;修复 penalize/approveOriginalComment 缺少乐观锁重试;修复误报反馈端点无法重试 FAIL 状态;修复 tag-NEUTRAL 缺少样式、handleTriggerAiReply 缺少加载保护;修复 filterKeyword 未做防抖、performCleanup 逻辑错误;优化 extractChoice 优先匹配违规类别避免误判
This commit is contained in:
sunny-335
2026-07-01 10:52:28 +08:00
parent 9ac25c4081
commit 615935d947
10 changed files with 167 additions and 88 deletions
+23
View File
@@ -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<String>` 并使用 `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
+1 -1
View File
@@ -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
@@ -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回复正在后台生成" : "已标记为误报并通过"
)));
@@ -127,6 +127,8 @@ class AiFoundationDelegate {
/**
* 从 chat 响应文本中提取匹配的分类值。
* 优先精确匹配,其次包含匹配。
* 包含匹配时优先匹配违规类别(广告/辱骂/敏感/无意义),最后才匹配"正常",
* 避免 AI 解释性文本中同时出现"正常"和违规词时误判为"正常"。
* 无匹配时返回空字符串(触发 defaultIfEmpty 安全拦截),避免原始文本被误判为违规类别。
*/
static String extractChoice(String text, List<String> 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 "";
@@ -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);
@@ -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.<Comment>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.<Reply>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();
}
@@ -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<String> categories = (spec != null && spec.getCategories() != null)
? spec.getCategories() : List.of();
// 3. Tag annotations (fallback if no category match)
List<String> 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<String> resolveFromCategories(List<String> 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<String> resolveFromTags(List<String> tagNames) {
if (tagNames == null || tagNames.isEmpty()) {
return Mono.empty();
}
return Flux.fromIterable(tagNames)
.concatMap(this::resolveFromTag)
.next();
}
private Mono<String> 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<String> 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();
});
}
/**
+1 -1
View File
@@ -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"
+22 -7
View File
@@ -76,7 +76,10 @@
<svg class="filter-icon fp-icon-ok" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
<span class="filter-category">误报</span>
<span class="filter-detail">{{ reply.spec.filterReason || '用户确认为误报' }}</span>
<button class="btn-trigger-ai" @click="handleTriggerAiReply(reply)">触发AI回复</button>
<button class="btn-trigger-ai" :disabled="triggerAiLoadingName === reply.metadata.name" @click="handleTriggerAiReply(reply)">
<span v-if="triggerAiLoadingName === reply.metadata.name" class="fp-spinner"></span>
触发AI回复
</button>
</div>
</div>
</div>
@@ -176,7 +179,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from "vue"
import { ref, onMounted, onUnmounted, watch } from "vue"
import { axiosInstance } from "@halo-dev/api-client"
import { VPageHeader, VButton, VLoading, Toast } from "@halo-dev/components"
import { IconPlug } from "@halo-dev/components"
@@ -189,6 +192,7 @@ const selectedNames = ref<Set<string>>(new Set()); const selectAll = ref(false);
const filterStatus = ref(""); const filterSentiment = ref(""); const filterKeyword = ref("");
const showDialog = ref(false); const conversationLoading = ref(false); const conversationMessages = ref<ConversationMessage[]>([]);
const showFalsePositiveDialog = ref(false); const falsePositiveTarget = ref<AiCommentReplyItem | null>(null); const fpLoading = ref(false);
const triggerAiLoadingName = ref<string | null>(null);
const toggleSelect = (name: string) => { selectedNames.value.has(name) ? selectedNames.value.delete(name) : selectedNames.value.add(name); selectAll.value = replies.value.length > 0 && replies.value.every(r => selectedNames.value.has(r.metadata.name)) }
const toggleSelectAll = () => { if (selectAll.value) { selectedNames.value.clear(); selectAll.value = false } else { selectedNames.value = new Set(replies.value.map(r => r.metadata.name)); selectAll.value = true } }
@@ -255,17 +259,26 @@ const handleFalsePositive = async (action: string) => {
} finally { fpLoading.value = false }
}
const handleTriggerAiReply = async (reply: AiCommentReplyItem) => {
if (triggerAiLoadingName.value) return
triggerAiLoadingName.value = reply.metadata.name
try {
await axiosInstance.post(`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies/${reply.metadata.name}/false-positive`, { action: "aiReply" })
Toast.success("AI回复正在后台生成")
fetchReplies()
} catch (e: any) {
Toast.error(e?.response?.data?.message || "触发失败")
}
} finally { triggerAiLoadingName.value = null }
}
watch([filterStatus, filterSentiment, filterKeyword], () => { page.value = 1; fetchReplies() })
// 状态/情感筛选立即触发;关键词输入防抖 300ms 避免每次按键都请求
watch([filterStatus, filterSentiment], () => { page.value = 1; fetchReplies() })
let keywordDebounceTimer: ReturnType<typeof setTimeout> | null = null
watch(filterKeyword, () => {
if (keywordDebounceTimer) clearTimeout(keywordDebounceTimer)
keywordDebounceTimer = setTimeout(() => { page.value = 1; fetchReplies() }, 300)
})
watch(page, () => { selectedNames.value.clear(); selectAll.value = false; fetchReplies() })
onMounted(fetchReplies)
onUnmounted(() => { if (keywordDebounceTimer) clearTimeout(keywordDebounceTimer) })
</script>
<style scoped>
@@ -309,8 +322,10 @@ onMounted(fetchReplies)
.btn-false-positive { flex-shrink: 0; margin-left: auto; padding: 2px 8px; border: 1px solid #b45309; border-radius: 4px; background: transparent; color: #b45309; font-size: 11px; cursor: pointer; white-space: nowrap; transition: all 0.15s; }
.btn-false-positive:hover { background: #b45309; color: #fff; }
.fp-icon-ok { color: #16a34a; }
.btn-trigger-ai { flex-shrink: 0; margin-left: auto; padding: 2px 8px; border: 1px solid #2563eb; border-radius: 4px; background: transparent; color: #2563eb; font-size: 11px; cursor: pointer; white-space: nowrap; transition: all 0.15s; }
.btn-trigger-ai:hover { background: #2563eb; color: #fff; }
.btn-trigger-ai { flex-shrink: 0; margin-left: auto; padding: 2px 8px; border: 1px solid #2563eb; border-radius: 4px; background: transparent; color: #2563eb; font-size: 11px; cursor: pointer; white-space: nowrap; transition: all 0.15s; display: inline-flex; align-items: center; gap: 4px; }
.btn-trigger-ai:hover:not(:disabled) { background: #2563eb; color: #fff; }
.btn-trigger-ai:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-trigger-ai .fp-spinner { width: 11px; height: 11px; border-color: rgba(37,99,235,0.3); border-top-color: #2563eb; }
.card-footer { display: flex; flex-direction: column; gap: 12px; padding: 12px 16px; background: #f9fafb; border-top: 1px solid #f3f4f6; }
@media (min-width: 640px) { .card-footer { flex-direction: row; justify-content: space-between; align-items: center; } }
.footer-info { font-size: 12px; color: #6b7280; display: flex; flex-wrap: wrap; gap: 12px; }
@@ -329,7 +344,7 @@ onMounted(fetchReplies)
.tag-PASS { background: #dcfce7; color: #15803d; } .tag-FAIL { background: #fee2e2; color: #b91c1c; } .tag-PENDING { background: #fef9c3; color: #a16207; } .tag-REJECTED { background: #ffedd5; color: #c2410c; } .tag-FILTERED { background: #f1f5f9; color: #b45309; border: 1px solid #fde68a; } .tag-FALSE_POSITIVE { background: #dbeafe; color: #1d4ed8; border: 1px solid #93c5fd; }
.tag-published { background: #dbeafe; color: #1d4ed8; } .tag-draft { background: #f3f4f6; color: #4b5563; }
.tag-conv { background: #f3e8ff; color: #7e22ce; }
.tag-VERY_POSITIVE { background: #dcfce7; color: #14532d; } .tag-POSITIVE { background: #ecfdf5; color: #15803d; } .tag-NEGATIVE { background: #ffe4e6; color: #e11d48; } .tag-VERY_NEGATIVE { background: #fee2e2; color: #991b1b; }
.tag-VERY_POSITIVE { background: #dcfce7; color: #14532d; } .tag-POSITIVE { background: #ecfdf5; color: #15803d; } .tag-NEUTRAL { background: #f3f4f6; color: #4b5563; } .tag-NEGATIVE { background: #ffe4e6; color: #e11d48; } .tag-VERY_NEGATIVE { background: #fee2e2; color: #991b1b; }
/* 对话弹窗与响应式气泡 */
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999; backdrop-filter: blur(2px); padding: 16px; box-sizing: border-box; }
+1 -1
View File
@@ -309,7 +309,7 @@ const filteredCommenters = computed(() => { const kw = commenterSearch.value.tri
const openCommenterDialog = async () => { showCommenterDialog.value = true; commenterLoading.value = true; try { const { data } = await axiosInstance.get(`${apiBase}/commenters`); commenterList.value = data.items || data } catch(e) { commenterList.value = [] } finally { commenterLoading.value = false } }
const addCommenter = (c: any) => { const v = c.email || c.displayName; const cur = settings.basic.blockedCommenters.split(",").map(s=>s.trim()).filter(Boolean); if(cur.includes(v)) return; cur.push(v); settings.basic.blockedCommenters = cur.join(","); Toast.success("已添加"); showCommenterDialog.value = false }
const cleanupLoading = ref(false); const cleanupResult = ref<number | null>(null)
const performCleanup = async () => { cleanupLoading.value=true; try { const { data } = await axiosInstance.post(`${apiBase}/cleanup`); cleanupResult.value = data.deletedCount ?? data ?? 0; Toast.success("清理完成") } catch(e){ Toast.error("清理失败") } finally { cleanupLoading.value=false } }
const performCleanup = async () => { cleanupLoading.value=true; try { const { data } = await axiosInstance.post(`${apiBase}/cleanup`); cleanupResult.value = typeof data === 'number' ? data : (data?.deletedCount ?? 0); Toast.success("清理完成") } catch(e){ Toast.error("清理失败") } finally { cleanupLoading.value=false } }
// Persona
const personasApiBase = `${apiBase}/personas`