From 0959196c34ddf1200187f3509f95888e21ddad90 Mon Sep 17 00:00:00 2001 From: nxxy335 Date: Sun, 27 Sep 2026 22:39:38 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=98=E5=8E=9F=201.4.1=20=E5=9F=BA=E7=BA=BF?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=9B=9E=E8=AF=84=E5=BC=80=E5=85=B3?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=8D=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 还原工程:以 1.4.1 安装包为来源,补齐缺失的 filterOnly 配置/编排逻辑/UI 开关,gradle build 全绿(5 测试通过) - 修复「开启回评」:annotation 改用字符串 on/off 契约,FilterService 归一化解析成为唯一判定入口,默认开启、显式关闭生效、重启不丢;新增 FilterServiceTest 锁定行为 - 整体健壮性:白名单/黑名单(兼容数组形态、正则预编译)、清理天数钳制 [1,365]、UI parseCfg 空值判定修正 Co-Authored-By: WorkBuddy --- docs/guide/filter.md | 15 ++ .../service/AiReplyCleanupService.java | 5 +- .../service/AiReplyOrchestrator.java | 57 +++++-- .../service/FilterService.java | 152 ++++++++++++++---- .../service/WhitelistService.java | 15 +- .../extensions/annotation-setting.yaml | 12 +- src/main/resources/extensions/settings.yaml | 6 + .../service/FilterServiceTest.java | 127 +++++++++++++++ ui/pnpm-lock.yaml | 8 + ui/src/views/SettingsView.vue | 13 +- 10 files changed, 363 insertions(+), 47 deletions(-) create mode 100644 src/test/java/top/nxxy335/commentaiautopilot/service/FilterServiceTest.java diff --git a/docs/guide/filter.md b/docs/guide/filter.md index 6c517ab..2e7feaf 100644 --- a/docs/guide/filter.md +++ b/docs/guide/filter.md @@ -32,6 +32,21 @@ 3. 找到 **启用AI回评** 开关 4. 根据需要开启或关闭 +!!! note "实现约定(改动前请先阅读)" + + `metadata.annotations` 的值只能是字符串,因此该开关在 `annotation-setting.yaml` 中 + 必须使用 `on-value: "true"` / `off-value: "false"` 显式声明字符串取值。 + 运行时判定入口为 `FilterService#resolveAnnotation`,其缺省值与 `annotation-setting.yaml` + 中的表单默认值必须保持一致: + + | 来源 | 文章(Post) | 页面(SinglePage) | + |------|-------------|-------------------| + | 表单默认值 | `value: "true"` | `value: "false"` | + | 运行时缺省 | `DEFAULT_POST_REPLY_ENABLED = true` | `pagesEnabled`(默认 `false`) | + + 若只想关闭某个页面的回评:在页面编辑器中把开关切到关闭并保存即可(会写入 + `ai-reply-enabled: "false"`,运行时优先于全局开关)。 + ## 评论者黑名单 评论者黑名单功能可以屏蔽指定评论者,使其评论不触发AI回复。 diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java index 43db9a8..d798e11 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java @@ -115,8 +115,11 @@ public class AiReplyCleanupService implements DisposableBean { if (cleanupJson == null || cleanupJson.isBlank()) return 30; try { JsonNode node = objectMapper.readTree(cleanupJson); - return node.has("retentionDays") ? node.get("retentionDays").asInt(30) : 30; + int raw = node.has("retentionDays") ? node.get("retentionDays").asInt(30) : 30; + // 与 settings.yaml 中 retentionDays 的 min/max 对齐,避免非法值影响清理时机 + return Math.max(1, Math.min(365, raw)); } catch (Exception e) { + log.warn("[Cleanup] Failed to parse retentionDays config: {}", e.getMessage()); return 30; } }) diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java index b1dff21..aaddd17 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java @@ -268,15 +268,22 @@ public class AiReplyOrchestrator { .then(preFilterService.penalize(commentName, replyName)) .then(); } - return sentimentService.analyzeSentiment(context.commentContent(), modelName) - .flatMap(sentimentResult -> { - log.info("[Orchestrator] Sentiment for {}: {} (confidence: {})", - commentName, sentimentResult.sentiment(), sentimentResult.confidence()); - return promptBuilder.buildPrompt(context, sentimentResult.sentiment(), personaName) - .flatMap(prompt -> createAiCommentReply(context, sentimentResult.sentiment(), personaName) - .flatMap(replyRecord -> generateAndPublish(prompt, context, replyRecord, modelName, personaName)) - ); - }); + return isFilterOnlyEnabled() + .flatMap(filterOnly -> { + if (filterOnly) { + log.info("[Orchestrator] Filter-only mode enabled, skipping AI reply for: {}", commentName); + return Mono.empty(); + } + return sentimentService.analyzeSentiment(context.commentContent(), modelName) + .flatMap(sentimentResult -> { + log.info("[Orchestrator] Sentiment for {}: {} (confidence: {})", + commentName, sentimentResult.sentiment(), sentimentResult.confidence()); + return promptBuilder.buildPrompt(context, sentimentResult.sentiment(), personaName) + .flatMap(prompt -> createAiCommentReply(context, sentimentResult.sentiment(), personaName) + .flatMap(replyRecord -> generateAndPublish(prompt, context, replyRecord, modelName, personaName)) + ); + }); + }); }); }) ) @@ -567,6 +574,38 @@ public class AiReplyOrchestrator { .defaultIfEmpty(true); } + /** + * 读取「仅过滤不回评」开关。 + * + *

开启后,评论可通过前置过滤(标记违规并取消通过),但不会生成与发布 AI 回复。 + * 该字段缺省或解析失败时一律视为 false(保留原有生成行为),确保默认行为与 + * settings.yaml 中 {@code filterOnly: false} 的默认值完全一致。 + */ + private Mono isFilterOnlyEnabled() { + return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) + .mapNotNull(cm -> { + var data = cm.getData(); + if (data == null) return false; + String basicJson = data.get("basic"); + if (basicJson == null || basicJson.isBlank()) return false; + try { + JsonNode node = objectMapper.readTree(basicJson); + if (!node.has("filterOnly")) { + return false; + } + return node.get("filterOnly").asBoolean(false); + } catch (Exception e) { + log.warn("[Orchestrator] Failed to parse filterOnly from ConfigMap: {}", e.getMessage()); + return false; + } + }) + .onErrorResume(e -> { + log.debug("[Orchestrator] Failed to fetch filterOnly setting: {}", e.getMessage()); + return Mono.just(false); + }) + .defaultIfEmpty(false); + } + private Mono getMaxRetryCount() { return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) .mapNotNull(cm -> { diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java index be2240d..dd4d8d8 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java @@ -14,6 +14,9 @@ import run.halo.app.extension.ReactiveExtensionClient; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -29,6 +32,27 @@ public class FilterService { private static final String GROUP_CONTENT = "content.halo.run"; private static final String GROUP_MOMENT = "moment.halo.run"; + /** + * 文章(Post)缺省开启 AI 回评。必须与 annotation-setting.yaml 中 Post 表单的 + * {@code value: "true"} 保持一致,否则界面显示与实际行为会不一致。 + */ + private static final boolean DEFAULT_POST_REPLY_ENABLED = true; + + /** + * 独立页面缺省值取自全局开关「启用页面AI回复」{@code pagesEnabled}, + * 与 annotation-setting.yaml 中 SinglePage 表单的 {@code value: "false"} 以及 + * settings.yaml 中 {@code pagesEnabled: false} 三者保持一致。 + */ + private static final boolean DEFAULT_PAGE_REPLY_ENABLED = false; + + /** 标注值中表示"开启"的合法写法(已归一化)。 */ + private static final java.util.Set TRUE_LITERALS = + Set.of("true", "1", "on", "yes"); + + /** 标注值中表示"关闭"的合法写法(已归一化)。 */ + private static final java.util.Set FALSE_LITERALS = + Set.of("false", "0", "off", "no"); + public FilterService(ReactiveExtensionClient client, ObjectMapper objectMapper) { this.client = client; this.objectMapper = objectMapper; @@ -81,9 +105,7 @@ public class FilterService { if (basicJson == null || basicJson.isBlank()) return false; try { JsonNode node = objectMapper.readTree(basicJson); - String blockedCommentersStr = node.has("blockedCommenters") - ? node.get("blockedCommenters").asText("") : ""; - List blockedCommenters = parseList(blockedCommentersStr); + List blockedCommenters = parseList(readStringList(node, "blockedCommenters")); String commenterName = getCommenterDisplayName(comment); String commenterEmail = getCommenterEmail(comment); if (isInList(commenterName, blockedCommenters) || isInList(commenterEmail, blockedCommenters)) { @@ -109,12 +131,16 @@ public class FilterService { String name = subjectRef.getName(); if (GROUP_CONTENT.equals(group) && "Post".equals(kind)) { + // 缺省值 DEFAULT_POST_REPLY_ENABLED 与 annotation-setting.yaml 中文章表单的 + // value: "true" 一致,保证"界面显示开启"与"运行时按开启执行"始终相同 return client.fetch(Post.class, name) - .map(post -> resolveAnnotation(post.getMetadata().getAnnotations(), true)) - .defaultIfEmpty(true); + .map(post -> resolveAnnotation(post.getMetadata().getAnnotations(), DEFAULT_POST_REPLY_ENABLED)) + .defaultIfEmpty(DEFAULT_POST_REPLY_ENABLED); } if (GROUP_CONTENT.equals(group) && "SinglePage".equals(kind)) { + // 页面未显式配置标注时,沿用全局「启用页面AI回复」作为缺省值; + // 一旦页面标注存在(表单回写的 "true"/"false" 字符串),标注优先。 return getPagesEnabled() .flatMap(pagesEnabled -> client.fetch(SinglePage.class, name) @@ -159,44 +185,72 @@ public class FilterService { .defaultIfEmpty(true); } + /** + * 读取全局「启用页面AI回复」开关。所有缺省分支统一返回 {@link #DEFAULT_PAGE_REPLY_ENABLED}, + * 与 settings.yaml 中 {@code pagesEnabled: false} 保持一致。 + */ private Mono getPagesEnabled() { return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) .mapNotNull(cm -> { var data = cm.getData(); - if (data == null) return false; + if (data == null) return DEFAULT_PAGE_REPLY_ENABLED; String basicJson = data.get("basic"); - if (basicJson == null || basicJson.isBlank()) return false; + if (basicJson == null || basicJson.isBlank()) return DEFAULT_PAGE_REPLY_ENABLED; try { JsonNode node = objectMapper.readTree(basicJson); if (!node.has("pagesEnabled")) { - return false; + return DEFAULT_PAGE_REPLY_ENABLED; } - return node.get("pagesEnabled").asBoolean(false); + return node.get("pagesEnabled").asBoolean(DEFAULT_PAGE_REPLY_ENABLED); } catch (Exception e) { log.warn("[Filter] Failed to parse pagesEnabled: {}", e.getMessage()); - return false; + return DEFAULT_PAGE_REPLY_ENABLED; } }) .onErrorResume(e -> { log.debug("[Filter] Failed to fetch pagesEnabled: {}", e.getMessage()); - return Mono.just(false); + return Mono.just(DEFAULT_PAGE_REPLY_ENABLED); }) - .defaultIfEmpty(false); + .defaultIfEmpty(DEFAULT_PAGE_REPLY_ENABLED); } - private boolean resolveAnnotation(java.util.Map annotations, boolean defaultEnabled) { + /** + * 解析文章的「启用AI回评」标注值。 + * + *

这是开关显示值(annotation-setting.yaml 的 {@code value})与运行时判定共用的唯一判定入口, + * 二者必须返回一致结果,否则会出现"界面显示关闭但仍在回评"的开关失效问题。约定: + *

+ * + * @param annotations 资源 metadata.annotations + * @param defaultEnabled 缺省时的取值,必须与 annotation-setting.yaml 中该类型的表单默认值一致 + */ + private boolean resolveAnnotation(Map annotations, boolean defaultEnabled) { if (annotations == null || !annotations.containsKey(ANNOTATION_KEY)) { return defaultEnabled; } String value = annotations.get(ANNOTATION_KEY); - if ("false".equalsIgnoreCase(value)) { + if (value == null) { + return defaultEnabled; + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + if (normalized.isEmpty()) { + // 空串等同于未配置,保持与表单缺省值一致,避免开关显示关闭却仍回评 + return defaultEnabled; + } + if (FALSE_LITERALS.contains(normalized)) { log.info("[Filter] Annotation {} is set to false, skipping", ANNOTATION_KEY); return false; } - if ("true".equalsIgnoreCase(value)) { + if (TRUE_LITERALS.contains(normalized)) { return true; } - // Unrecognized value, fall back to default + log.warn("[Filter] Annotation {} has unrecognized value '{}', falling back to default {}. " + + "Expected one of true/false/1/0/on/off/yes/no.", ANNOTATION_KEY, value, defaultEnabled); return defaultEnabled; } @@ -224,20 +278,62 @@ public class FilterService { .collect(Collectors.toList()); } + /** + * 读取配置中可能是字符串或数组形式的名单字段。 + * + *

历史版本可能把名单以 JSON 数组写入,{@link JsonNode#asText(String)} 对数组会返回空串, + * 导致名单被静默忽略,因此这里统一兼容两种形态。 + * + * @param node 配置节点 + * @param key 字段名 + * @return 解析后的原始文本(逗号分隔);字段不存在时返回空串 + */ + private String readStringList(JsonNode node, String key) { + if (node == null || !node.has(key)) { + return ""; + } + JsonNode value = node.get(key); + if (value == null || value.isNull()) { + return ""; + } + if (value.isArray()) { + StringBuilder joined = new StringBuilder(); + for (JsonNode element : value) { + if (element == null || element.isNull()) { + continue; + } + if (joined.length() > 0) { + joined.append(','); + } + joined.append(element.asText("")); + } + return joined.toString(); + } + return value.asText(""); + } + private boolean isInList(String value, List list) { if (value == null || value.isEmpty() || list.isEmpty()) return false; - return list.stream().anyMatch(item -> { - if (item.startsWith("regex:")) { - try { - String regex = item.substring(6); - Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE); - return pattern.matcher(value).matches(); - } catch (Exception e) { - log.warn("[Filter] Invalid regex pattern '{}': {}", item, e.getMessage()); - return false; - } + // 每条正则只编译一次(原实现会在每个候选上重复编译,命中率越低开销越大) + Map compiled = new java.util.LinkedHashMap<>(); + for (String item : list) { + if (item == null || item.isEmpty()) continue; + if (!item.startsWith("regex:")) { + if (item.equalsIgnoreCase(value)) return true; + continue; } - return item.equalsIgnoreCase(value); - }); + Pattern pattern = compiled.computeIfAbsent(item, key -> { + try { + return Pattern.compile(key.substring("regex:".length()), Pattern.CASE_INSENSITIVE); + } catch (Exception e) { + log.warn("[Filter] Invalid regex pattern '{}': {}", key, e.getMessage()); + return null; + } + }); + if (pattern != null && pattern.matcher(value).matches()) { + return true; + } + } + return false; } } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/WhitelistService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/WhitelistService.java index ead605d..63442ec 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/WhitelistService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/WhitelistService.java @@ -96,18 +96,25 @@ public class WhitelistService { cm.setData(data); } String basicJson = data.getOrDefault("basic", "{}"); + String merged; try { JsonNode node = objectMapper.readTree(basicJson); ObjectNode objectNode = node.isObject() ? (ObjectNode) node.deepCopy() : objectMapper.createObjectNode(); objectNode.put("whitelistedCommenters", joined); - data.put("basic", objectMapper.writeValueAsString(objectNode)); + merged = objectMapper.writeValueAsString(objectNode); } catch (Exception e) { - log.warn("[Whitelist] 序列化白名单失败,使用裸 JSON 写入: {}", e.getMessage()); - // 退化方案:直接拼接 JSON - data.put("basic", "{\"whitelistedCommenters\":" + objectMapper.valueToTree(joined) + "}"); + // basic 已损坏时用最少的字段重建,避免连带清除 whitelistEnabled 等其它配置; + // 仍失败则向上抛错,由调用方提示保存失败,而不是静默写入半截配置。 + log.warn("[Whitelist] basic 配置无法解析,将以仅含名单字段的方式重建: {}", e.getMessage()); + try { + merged = "{\"whitelistedCommenters\":" + objectMapper.writeValueAsString(joined) + "}"; + } catch (Exception inner) { + return Mono.error(new IllegalStateException("无法解析 basic 配置,白名单未保存", inner)); + } } + data.put("basic", merged); return client.update(cm); }) .then(); diff --git a/src/main/resources/extensions/annotation-setting.yaml b/src/main/resources/extensions/annotation-setting.yaml index 2ad45a7..ed05e11 100644 --- a/src/main/resources/extensions/annotation-setting.yaml +++ b/src/main/resources/extensions/annotation-setting.yaml @@ -10,7 +10,11 @@ spec: - $formkit: switch name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled label: 启用AI回评 - value: true + # metadata.annotations 的值必须是字符串,因此布尔开关必须使用 on-value / off-value + # 显式声明字符串取值,value 也必须是字符串,否则表单回落为"关闭"而运行时仍按开启处理。 + on-value: "true" + off-value: "false" + value: "true" help: 开启后,该文章收到评论时将自动触发AI回复 - $formkit: text name: comment-ai-autopilot.nxxy335.top/ai-persona @@ -30,7 +34,11 @@ spec: - $formkit: switch name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled label: 启用AI回评 - value: false + # 与文章保持一致:布尔开关必须输出字符串,默认值与运行时的缺省策略一致 + # (页面缺省值取自全局「启用页面AI回复」pagesEnabled,默认为 false) + on-value: "true" + off-value: "false" + value: "false" help: 开启后,该页面收到评论时将自动触发AI回复 - $formkit: text name: comment-ai-autopilot.nxxy335.top/ai-persona diff --git a/src/main/resources/extensions/settings.yaml b/src/main/resources/extensions/settings.yaml index 1269c7d..fb3ef85 100644 --- a/src/main/resources/extensions/settings.yaml +++ b/src/main/resources/extensions/settings.yaml @@ -56,6 +56,12 @@ spec: label: 启用前置过滤 help: "AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token" value: true + - $formkit: switch + name: filterOnly + label: 仅过滤不回评 + help: "开启后只进行AI评论过滤拦截,不生成AI回复。需先启用前置过滤" + value: false + condition: "{{ preFilterEnabled }}" - $formkit: switch name: preFilterPendingOnViolation label: 违规评论设为待审核 diff --git a/src/test/java/top/nxxy335/commentaiautopilot/service/FilterServiceTest.java b/src/test/java/top/nxxy335/commentaiautopilot/service/FilterServiceTest.java new file mode 100644 index 0000000..263fed6 --- /dev/null +++ b/src/test/java/top/nxxy335/commentaiautopilot/service/FilterServiceTest.java @@ -0,0 +1,127 @@ +package top.nxxy335.commentaiautopilot.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import run.halo.app.core.extension.content.Post; +import run.halo.app.core.extension.content.Comment; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.Ref; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +/** + * 「开启AI回评」开关的状态一致性测试。 + * + *

背景:文章设置里的布尔开关一度出现"界面显示关闭、运行时却仍按开启执行"的失效问题。 + * 根因是 annotation-setting.yaml 未使用字符串取值(on-value / off-value), + * 且运行时对标注值的解析只认严格的小写 "true"/"false",其它取值被静默丢弃。 + * + *

本测试把"界面显示值 == 运行时实际行为"这条契约固定下来。 + */ +class FilterServiceTest { + + private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap"; + private static final String ANNOTATION_KEY = "comment-ai-autopilot.nxxy335.top/ai-reply-enabled"; + private static final String TARGET_NAME = "target-post"; + + private final StubClient stub = new StubClient(); + private final FilterService filterService = new FilterService(stub.client, new ObjectMapper()); + + /** + * 文章未配置标注时必须默认开启: + * 这与 annotation-setting.yaml 中 Post 表单的 {@code value: "true"} 一致, + * 否则会出现"界面显示开启、行为关闭"的反向不一致。 + */ + @Test + void postDefaultsToEnabledWhenAnnotationAbsent() { + assertTrue(aiReplyEnabled(null), "annotations 为 null 时默认开启"); + assertTrue(aiReplyEnabled(Map.of()), "annotations 为空 Map 时默认开启"); + assertTrue(aiReplyEnabled(Map.of("unrelated", "true")), "不含目标标注时默认开启"); + } + + /** 显式关闭必须生效——这是"想关闭回评时能否生效"的回归点。 */ + @Test + void postCanBeExplicitlyDisabled() { + assertFalse(aiReplyEnabled(Map.of(ANNOTATION_KEY, "false"))); + assertFalse(aiReplyEnabled(Map.of(ANNOTATION_KEY, "FALSE")), "忽略大小写"); + assertFalse(aiReplyEnabled(Map.of(ANNOTATION_KEY, " false ")), "容忍首尾空白"); + assertFalse(aiReplyEnabled(Map.of(ANNOTATION_KEY, "0"))); + assertFalse(aiReplyEnabled(Map.of(ANNOTATION_KEY, "off"))); + assertFalse(aiReplyEnabled(Map.of(ANNOTATION_KEY, "no"))); + } + + @Test + void postCanBeExplicitlyEnabled() { + assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, "true"))); + assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, "TRUE")), "忽略大小写"); + assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, "1"))); + assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, "on"))); + assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, "yes"))); + assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, " true ")), "容忍首尾空白"); + } + + /** + * 脏数据(空串、无法识别的取值)回落到默认开启并告警, + * 而不是被误判成"关闭"——那样会造成显示关闭却依然回评。 + */ + @Test + void blankAndUnknownValuesFallBackToDefault() { + assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, "")), "空串等同于未配置"); + assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, " ")), "纯空白等同于未配置"); + assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, "maybe")), "无法识别的取值回落为默认开启"); + } + + // ── helpers ────────────────────────────────────────────────────────── + + /** + * 构造一条指向指定文章的评论,走完整的 shouldProcess 运行时判定路径。 + */ + private boolean aiReplyEnabled(Map annotations) { + var post = new Post(); + var postMetadata = new Metadata(); + postMetadata.setAnnotations(annotations); + post.setMetadata(postMetadata); + stub.post = post; + + var comment = new Comment(); + var spec = new Comment.CommentSpec(); + var subjectRef = new Ref(); + subjectRef.setGroup("content.halo.run"); + subjectRef.setKind("Post"); + subjectRef.setName(TARGET_NAME); + spec.setSubjectRef(subjectRef); + comment.setSpec(spec); + + return Boolean.TRUE.equals(filterService.shouldProcess(comment).block()); + } + + /** + * 只提供测试需要的最小 ReactiveExtensionClient 行为: + * ConfigMap 返回无黑名单数据的配置,Post 由测试用例在每次调用前替换。 + */ + private static final class StubClient { + + private final run.halo.app.extension.ReactiveExtensionClient client = + mock(run.halo.app.extension.ReactiveExtensionClient.class); + + private Post post; + + private StubClient() { + var configMap = new ConfigMap(); + configMap.setData(new HashMap<>()); + lenient().when(client.fetch(eq(ConfigMap.class), eq(CONFIG_MAP_NAME))) + .thenReturn(Mono.just(configMap)); + lenient().when(client.fetch(eq(Post.class), eq(TARGET_NAME))) + .thenAnswer(invocation -> Mono.just(post)); + } + } +} diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 06787b7..379bda7 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: axios: specifier: ^1.13.5 version: 1.17.0 + canvas-confetti: + specifier: ^1.9.3 + version: 1.9.4 vue: specifier: ^3.5.28 version: 3.5.38(typescript@5.8.3) @@ -1031,6 +1034,9 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + canvas-confetti@1.9.4: + resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -3452,6 +3458,8 @@ snapshots: callsites@3.1.0: {} + canvas-confetti@1.9.4: {} + chai@6.2.2: {} chalk@4.1.2: diff --git a/ui/src/views/SettingsView.vue b/ui/src/views/SettingsView.vue index 8fb029f..5fdddeb 100644 --- a/ui/src/views/SettingsView.vue +++ b/ui/src/views/SettingsView.vue @@ -118,6 +118,10 @@

启用前置过滤AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token
+
+
仅过滤不回评开启后只进行AI评论过滤拦截,不生成AI回复。需先启用前置过滤
+ +
违规评论设为待审核检测到违规评论时自动取消通过,需人工审核
@@ -442,11 +446,11 @@ const DEFAULT_LANGUAGE_REQUIREMENT = `【语言要求】请用评论所使用的 // 配置对象:basic/model/prompt/cleanup 四组 const settings = reactive({ - basic: { autoReply: true, autoPublish: true, maxRetryCount: 3, blockedCommenters: "", allowedCommenters: "", maxConversationTurns: 10, rateLimitPerHour: 0, preFilterEnabled: true, preFilterPendingOnViolation: true, momentsEnabled: true, pagesEnabled: false, whitelistEnabled: true }, + basic: { autoReply: true, autoPublish: true, maxRetryCount: 3, blockedCommenters: "", allowedCommenters: "", maxConversationTurns: 10, rateLimitPerHour: 0, preFilterEnabled: true, filterOnly: false, preFilterPendingOnViolation: true, momentsEnabled: true, pagesEnabled: false, whitelistEnabled: true }, model: { modelName: "" }, persona: { categoryPersonaMap: {} as Record }, prompt: { personaIdentity: "", safetyReview: "", sentimentAdapter: "", outputGuidance: "", languageRequirement: "" }, - cleanup: { cleanupEnabled: false, retentionDays: 30 } + cleanup: { cleanupEnabled: true, retentionDays: 30 } }) // 分类角色映射:编辑行数据(与 settings.model.categoryPersonaMap 双向同步) @@ -684,7 +688,8 @@ const onNumberChange = (field: 'maxConversationTurns' | 'rateLimitPerHour') => { if (settings.basic[field] === 0) triggerZeroConfirm(field) } -const parseCfg = (d:any, k:string) => { const v = d[k]; if(!v) return {}; if(typeof v === 'string') { try{ return JSON.parse(v) }catch{ return {} } } return v } +// 解析 ConfigMap 中的 JSON 字段:仅 null/undefined 视为缺失(false/0 是合法配置值) +const parseCfg = (d:any, k:string) => { const v = d[k]; if(v === undefined || v === null) return {}; if(typeof v === 'string') { try{ return JSON.parse(v) }catch{ return {} } } return v } // 解析分类角色映射配置(兼容字符串/对象两种存储形式) const parseCategoryPersonaMap = (raw:any): Record => { if(!raw) return {}; if(typeof raw === 'string') { try { const o = JSON.parse(raw); return (o && typeof o === 'object') ? o : {} } catch { return {} } } return (typeof raw === 'object') ? raw : {} } @@ -708,6 +713,8 @@ const fetchSettings = async () => { } if (b.pagesEnabled === undefined) settings.basic.pagesEnabled = false if (b.whitelistEnabled === undefined) settings.basic.whitelistEnabled = true + // 兼容旧版本 ConfigMap:filterOnly 缺失时回填默认值,避免开关显示"未定义" + if (b.filterOnly === undefined) settings.basic.filterOnly = false if (b.whitelistedCommenters !== undefined && b.allowedCommenters === undefined) { settings.basic.allowedCommenters = b.whitelistedCommenters }