还原 1.4.1 基线并修复回评开关状态不一致

- 还原工程:以 1.4.1 安装包为来源,补齐缺失的 filterOnly 配置/编排逻辑/UI 开关,gradle build 全绿(5 测试通过)
- 修复「开启回评」:annotation 改用字符串 on/off 契约,FilterService 归一化解析成为唯一判定入口,默认开启、显式关闭生效、重启不丢;新增 FilterServiceTest 锁定行为
- 整体健壮性:白名单/黑名单(兼容数组形态、正则预编译)、清理天数钳制 [1,365]、UI parseCfg 空值判定修正

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
This commit is contained in:
2026-09-27 22:39:38 +08:00
co-authored by WorkBuddy
parent 207a013239
commit 0959196c34
10 changed files with 363 additions and 47 deletions
+15
View File
@@ -32,6 +32,21 @@
3. 找到 **启用AI回评** 开关 3. 找到 **启用AI回评** 开关
4. 根据需要开启或关闭 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回复。 评论者黑名单功能可以屏蔽指定评论者,使其评论不触发AI回复。
@@ -115,8 +115,11 @@ public class AiReplyCleanupService implements DisposableBean {
if (cleanupJson == null || cleanupJson.isBlank()) return 30; if (cleanupJson == null || cleanupJson.isBlank()) return 30;
try { try {
JsonNode node = objectMapper.readTree(cleanupJson); 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) { } catch (Exception e) {
log.warn("[Cleanup] Failed to parse retentionDays config: {}", e.getMessage());
return 30; return 30;
} }
}) })
@@ -267,6 +267,12 @@ public class AiReplyOrchestrator {
return createFilteredRecord(context, preFilterResult) return createFilteredRecord(context, preFilterResult)
.then(preFilterService.penalize(commentName, replyName)) .then(preFilterService.penalize(commentName, replyName))
.then(); .then();
}
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) return sentimentService.analyzeSentiment(context.commentContent(), modelName)
.flatMap(sentimentResult -> { .flatMap(sentimentResult -> {
@@ -278,6 +284,7 @@ public class AiReplyOrchestrator {
); );
}); });
}); });
});
}) })
) )
); );
@@ -567,6 +574,38 @@ public class AiReplyOrchestrator {
.defaultIfEmpty(true); .defaultIfEmpty(true);
} }
/**
* 读取「仅过滤不回评」开关。
*
* <p>开启后,评论可通过前置过滤(标记违规并取消通过),但不会生成与发布 AI 回复。
* 该字段缺省或解析失败时一律视为 false(保留原有生成行为),确保默认行为与
* settings.yaml 中 {@code filterOnly: false} 的默认值完全一致。
*/
private Mono<Boolean> 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<Integer> getMaxRetryCount() { private Mono<Integer> getMaxRetryCount() {
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
.mapNotNull(cm -> { .mapNotNull(cm -> {
@@ -14,6 +14,9 @@ import run.halo.app.extension.ReactiveExtensionClient;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; 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_CONTENT = "content.halo.run";
private static final String GROUP_MOMENT = "moment.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<String> TRUE_LITERALS =
Set.of("true", "1", "on", "yes");
/** 标注值中表示"关闭"的合法写法(已归一化)。 */
private static final java.util.Set<String> FALSE_LITERALS =
Set.of("false", "0", "off", "no");
public FilterService(ReactiveExtensionClient client, ObjectMapper objectMapper) { public FilterService(ReactiveExtensionClient client, ObjectMapper objectMapper) {
this.client = client; this.client = client;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
@@ -81,9 +105,7 @@ public class FilterService {
if (basicJson == null || basicJson.isBlank()) return false; if (basicJson == null || basicJson.isBlank()) return false;
try { try {
JsonNode node = objectMapper.readTree(basicJson); JsonNode node = objectMapper.readTree(basicJson);
String blockedCommentersStr = node.has("blockedCommenters") List<String> blockedCommenters = parseList(readStringList(node, "blockedCommenters"));
? node.get("blockedCommenters").asText("") : "";
List<String> blockedCommenters = parseList(blockedCommentersStr);
String commenterName = getCommenterDisplayName(comment); String commenterName = getCommenterDisplayName(comment);
String commenterEmail = getCommenterEmail(comment); String commenterEmail = getCommenterEmail(comment);
if (isInList(commenterName, blockedCommenters) || isInList(commenterEmail, blockedCommenters)) { if (isInList(commenterName, blockedCommenters) || isInList(commenterEmail, blockedCommenters)) {
@@ -109,12 +131,16 @@ public class FilterService {
String name = subjectRef.getName(); String name = subjectRef.getName();
if (GROUP_CONTENT.equals(group) && "Post".equals(kind)) { if (GROUP_CONTENT.equals(group) && "Post".equals(kind)) {
// 缺省值 DEFAULT_POST_REPLY_ENABLED 与 annotation-setting.yaml 中文章表单的
// value: "true" 一致,保证"界面显示开启"与"运行时按开启执行"始终相同
return client.fetch(Post.class, name) return client.fetch(Post.class, name)
.map(post -> resolveAnnotation(post.getMetadata().getAnnotations(), true)) .map(post -> resolveAnnotation(post.getMetadata().getAnnotations(), DEFAULT_POST_REPLY_ENABLED))
.defaultIfEmpty(true); .defaultIfEmpty(DEFAULT_POST_REPLY_ENABLED);
} }
if (GROUP_CONTENT.equals(group) && "SinglePage".equals(kind)) { if (GROUP_CONTENT.equals(group) && "SinglePage".equals(kind)) {
// 页面未显式配置标注时,沿用全局「启用页面AI回复」作为缺省值;
// 一旦页面标注存在(表单回写的 "true"/"false" 字符串),标注优先。
return getPagesEnabled() return getPagesEnabled()
.flatMap(pagesEnabled -> .flatMap(pagesEnabled ->
client.fetch(SinglePage.class, name) client.fetch(SinglePage.class, name)
@@ -159,44 +185,72 @@ public class FilterService {
.defaultIfEmpty(true); .defaultIfEmpty(true);
} }
/**
* 读取全局「启用页面AI回复」开关。所有缺省分支统一返回 {@link #DEFAULT_PAGE_REPLY_ENABLED},
* 与 settings.yaml 中 {@code pagesEnabled: false} 保持一致。
*/
private Mono<Boolean> getPagesEnabled() { private Mono<Boolean> getPagesEnabled() {
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
.mapNotNull(cm -> { .mapNotNull(cm -> {
var data = cm.getData(); var data = cm.getData();
if (data == null) return false; if (data == null) return DEFAULT_PAGE_REPLY_ENABLED;
String basicJson = data.get("basic"); String basicJson = data.get("basic");
if (basicJson == null || basicJson.isBlank()) return false; if (basicJson == null || basicJson.isBlank()) return DEFAULT_PAGE_REPLY_ENABLED;
try { try {
JsonNode node = objectMapper.readTree(basicJson); JsonNode node = objectMapper.readTree(basicJson);
if (!node.has("pagesEnabled")) { 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) { } catch (Exception e) {
log.warn("[Filter] Failed to parse pagesEnabled: {}", e.getMessage()); log.warn("[Filter] Failed to parse pagesEnabled: {}", e.getMessage());
return false; return DEFAULT_PAGE_REPLY_ENABLED;
} }
}) })
.onErrorResume(e -> { .onErrorResume(e -> {
log.debug("[Filter] Failed to fetch pagesEnabled: {}", e.getMessage()); 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<String, String> annotations, boolean defaultEnabled) { /**
* 解析文章的「启用AI回评」标注值。
*
* <p>这是开关显示值(annotation-setting.yaml 的 {@code value})与运行时判定共用的唯一判定入口,
* 二者必须返回一致结果,否则会出现"界面显示关闭但仍在回评"的开关失效问题。约定:
* <ul>
* <li>标注不存在或值为空串 → 使用 defaultEnabled(与表单默认值一致)</li>
* <li>显式 false → 一定关闭,任何情况下不被默认值覆盖</li>
* <li>显式 true → 一定开启</li>
* <li>其它无法识别的值 → 记录告警并回落到 defaultEnabled</li>
* </ul>
*
* @param annotations 资源 metadata.annotations
* @param defaultEnabled 缺省时的取值,必须与 annotation-setting.yaml 中该类型的表单默认值一致
*/
private boolean resolveAnnotation(Map<String, String> annotations, boolean defaultEnabled) {
if (annotations == null || !annotations.containsKey(ANNOTATION_KEY)) { if (annotations == null || !annotations.containsKey(ANNOTATION_KEY)) {
return defaultEnabled; return defaultEnabled;
} }
String value = annotations.get(ANNOTATION_KEY); 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); log.info("[Filter] Annotation {} is set to false, skipping", ANNOTATION_KEY);
return false; return false;
} }
if ("true".equalsIgnoreCase(value)) { if (TRUE_LITERALS.contains(normalized)) {
return true; 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; return defaultEnabled;
} }
@@ -224,20 +278,62 @@ public class FilterService {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* 读取配置中可能是字符串或数组形式的名单字段。
*
* <p>历史版本可能把名单以 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<String> list) { private boolean isInList(String value, List<String> list) {
if (value == null || value.isEmpty() || list.isEmpty()) return false; if (value == null || value.isEmpty() || list.isEmpty()) return false;
return list.stream().anyMatch(item -> { // 每条正则只编译一次(原实现会在每个候选上重复编译,命中率越低开销越大)
if (item.startsWith("regex:")) { Map<String, Pattern> 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;
}
Pattern pattern = compiled.computeIfAbsent(item, key -> {
try { try {
String regex = item.substring(6); return Pattern.compile(key.substring("regex:".length()), Pattern.CASE_INSENSITIVE);
Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
return pattern.matcher(value).matches();
} catch (Exception e) { } catch (Exception e) {
log.warn("[Filter] Invalid regex pattern '{}': {}", item, e.getMessage()); log.warn("[Filter] Invalid regex pattern '{}': {}", key, e.getMessage());
return null;
}
});
if (pattern != null && pattern.matcher(value).matches()) {
return true;
}
}
return false; return false;
} }
} }
return item.equalsIgnoreCase(value);
});
}
}
@@ -96,18 +96,25 @@ public class WhitelistService {
cm.setData(data); cm.setData(data);
} }
String basicJson = data.getOrDefault("basic", "{}"); String basicJson = data.getOrDefault("basic", "{}");
String merged;
try { try {
JsonNode node = objectMapper.readTree(basicJson); JsonNode node = objectMapper.readTree(basicJson);
ObjectNode objectNode = node.isObject() ObjectNode objectNode = node.isObject()
? (ObjectNode) node.deepCopy() ? (ObjectNode) node.deepCopy()
: objectMapper.createObjectNode(); : objectMapper.createObjectNode();
objectNode.put("whitelistedCommenters", joined); objectNode.put("whitelistedCommenters", joined);
data.put("basic", objectMapper.writeValueAsString(objectNode)); merged = objectMapper.writeValueAsString(objectNode);
} catch (Exception e) { } catch (Exception e) {
log.warn("[Whitelist] 序列化白名单失败,使用裸 JSON 写入: {}", e.getMessage()); // basic 已损坏时用最少的字段重建,避免连带清除 whitelistEnabled 等其它配置;
// 退化方案:直接拼接 JSON // 仍失败则向上抛错,由调用方提示保存失败,而不是静默写入半截配置。
data.put("basic", "{\"whitelistedCommenters\":" + objectMapper.valueToTree(joined) + "}"); 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); return client.update(cm);
}) })
.then(); .then();
@@ -10,7 +10,11 @@ spec:
- $formkit: switch - $formkit: switch
name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled
label: 启用AI回评 label: 启用AI回评
value: true # metadata.annotations 的值必须是字符串,因此布尔开关必须使用 on-value / off-value
# 显式声明字符串取值,value 也必须是字符串,否则表单回落为"关闭"而运行时仍按开启处理。
on-value: "true"
off-value: "false"
value: "true"
help: 开启后,该文章收到评论时将自动触发AI回复 help: 开启后,该文章收到评论时将自动触发AI回复
- $formkit: text - $formkit: text
name: comment-ai-autopilot.nxxy335.top/ai-persona name: comment-ai-autopilot.nxxy335.top/ai-persona
@@ -30,7 +34,11 @@ spec:
- $formkit: switch - $formkit: switch
name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled
label: 启用AI回评 label: 启用AI回评
value: false # 与文章保持一致:布尔开关必须输出字符串,默认值与运行时的缺省策略一致
# (页面缺省值取自全局「启用页面AI回复」pagesEnabled,默认为 false)
on-value: "true"
off-value: "false"
value: "false"
help: 开启后,该页面收到评论时将自动触发AI回复 help: 开启后,该页面收到评论时将自动触发AI回复
- $formkit: text - $formkit: text
name: comment-ai-autopilot.nxxy335.top/ai-persona name: comment-ai-autopilot.nxxy335.top/ai-persona
@@ -56,6 +56,12 @@ spec:
label: 启用前置过滤 label: 启用前置过滤
help: "AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token" help: "AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token"
value: true value: true
- $formkit: switch
name: filterOnly
label: 仅过滤不回评
help: "开启后只进行AI评论过滤拦截,不生成AI回复。需先启用前置过滤"
value: false
condition: "{{ preFilterEnabled }}"
- $formkit: switch - $formkit: switch
name: preFilterPendingOnViolation name: preFilterPendingOnViolation
label: 违规评论设为待审核 label: 违规评论设为待审核
@@ -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回评」开关的状态一致性测试。
*
* <p>背景:文章设置里的布尔开关一度出现"界面显示关闭、运行时却仍按开启执行"的失效问题。
* 根因是 annotation-setting.yaml 未使用字符串取值(on-value / off-value),
* 且运行时对标注值的解析只认严格的小写 "true"/"false",其它取值被静默丢弃。
*
* <p>本测试把"界面显示值 == 运行时实际行为"这条契约固定下来。
*/
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<String, String> 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));
}
}
}
+8
View File
@@ -20,6 +20,9 @@ importers:
axios: axios:
specifier: ^1.13.5 specifier: ^1.13.5
version: 1.17.0 version: 1.17.0
canvas-confetti:
specifier: ^1.9.3
version: 1.9.4
vue: vue:
specifier: ^3.5.28 specifier: ^3.5.28
version: 3.5.38(typescript@5.8.3) version: 3.5.38(typescript@5.8.3)
@@ -1031,6 +1034,9 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
canvas-confetti@1.9.4:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
chai@6.2.2: chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -3452,6 +3458,8 @@ snapshots:
callsites@3.1.0: {} callsites@3.1.0: {}
canvas-confetti@1.9.4: {}
chai@6.2.2: {} chai@6.2.2: {}
chalk@4.1.2: chalk@4.1.2:
+10 -3
View File
@@ -118,6 +118,10 @@
<div class="form-row__label"><span class="form-label">启用前置过滤</span><span class="form-hint">AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token</span></div> <div class="form-row__label"><span class="form-label">启用前置过滤</span><span class="form-hint">AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token</span></div>
<label class="toggle"><input type="checkbox" v-model="settings.basic.preFilterEnabled" /><span class="toggle__track"><span class="toggle__thumb"></span></span></label> <label class="toggle"><input type="checkbox" v-model="settings.basic.preFilterEnabled" /><span class="toggle__track"><span class="toggle__thumb"></span></span></label>
</div> </div>
<div v-if="settings.basic.preFilterEnabled" class="form-row">
<div class="form-row__label"><span class="form-label">仅过滤不回评</span><span class="form-hint">开启后只进行AI评论过滤拦截,不生成AI回复。需先启用前置过滤</span></div>
<label class="toggle"><input type="checkbox" v-model="settings.basic.filterOnly" /><span class="toggle__track"><span class="toggle__thumb"></span></span></label>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-row__label"><span class="form-label">违规评论设为待审核</span><span class="form-hint">检测到违规评论时自动取消通过,需人工审核</span></div> <div class="form-row__label"><span class="form-label">违规评论设为待审核</span><span class="form-hint">检测到违规评论时自动取消通过,需人工审核</span></div>
<label class="toggle"><input type="checkbox" v-model="settings.basic.preFilterPendingOnViolation" /><span class="toggle__track"><span class="toggle__thumb"></span></span></label> <label class="toggle"><input type="checkbox" v-model="settings.basic.preFilterPendingOnViolation" /><span class="toggle__track"><span class="toggle__thumb"></span></span></label>
@@ -442,11 +446,11 @@ const DEFAULT_LANGUAGE_REQUIREMENT = `【语言要求】请用评论所使用的
// 配置对象:basic/model/prompt/cleanup 四组 // 配置对象:basic/model/prompt/cleanup 四组
const settings = reactive({ 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: "" }, model: { modelName: "" },
persona: { categoryPersonaMap: {} as Record<string, string> }, persona: { categoryPersonaMap: {} as Record<string, string> },
prompt: { personaIdentity: "", safetyReview: "", sentimentAdapter: "", outputGuidance: "", languageRequirement: "" }, prompt: { personaIdentity: "", safetyReview: "", sentimentAdapter: "", outputGuidance: "", languageRequirement: "" },
cleanup: { cleanupEnabled: false, retentionDays: 30 } cleanup: { cleanupEnabled: true, retentionDays: 30 }
}) })
// 分类角色映射:编辑行数据(与 settings.model.categoryPersonaMap 双向同步) // 分类角色映射:编辑行数据(与 settings.model.categoryPersonaMap 双向同步)
@@ -684,7 +688,8 @@ const onNumberChange = (field: 'maxConversationTurns' | 'rateLimitPerHour') => {
if (settings.basic[field] === 0) triggerZeroConfirm(field) 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<string,string> => { 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 : {} } const parseCategoryPersonaMap = (raw:any): Record<string,string> => { 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.pagesEnabled === undefined) settings.basic.pagesEnabled = false
if (b.whitelistEnabled === undefined) settings.basic.whitelistEnabled = true 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) { if (b.whitelistedCommenters !== undefined && b.allowedCommenters === undefined) {
settings.basic.allowedCommenters = b.whitelistedCommenters settings.basic.allowedCommenters = b.whitelistedCommenters
} }