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})与运行时判定共用的唯一判定入口,
+ * 二者必须返回一致结果,否则会出现"界面显示关闭但仍在回评"的开关失效问题。约定:
+ *
+ * - 标注不存在或值为空串 → 使用 defaultEnabled(与表单默认值一致)
+ * - 显式 false → 一定关闭,任何情况下不被默认值覆盖
+ * - 显式 true → 一定开启
+ * - 其它无法识别的值 → 记录告警并回落到 defaultEnabled
+ *
+ *
+ * @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
+