From f184b94bcdbedcbc32f6ff9b3d05f3e6b6534c45 Mon Sep 17 00:00:00 2001
From: mateaix <57164338+mateaix@users.noreply.github.com>
Date: Sat, 11 Jul 2026 22:32:14 +0800
Subject: [PATCH] =?UTF-8?q?feat(content-studio):=20=E7=94=9F=E4=BA=A7?=
=?UTF-8?q?=E7=A1=AC=E5=8C=96=20=E2=80=94=E2=80=94=20=E6=AD=A3=E6=96=87?=
=?UTF-8?q?=E5=9B=BE=E4=B8=8A=E5=BE=AE=E4=BF=A1/=E5=AF=86=E9=92=A5?=
=?UTF-8?q?=E5=8A=A0=E5=AF=86/token=E5=A4=8D=E7=94=A8/=E5=86=85=E5=AE=B9?=
=?UTF-8?q?=E6=97=A5=E5=8E=86=E5=8E=BB=E9=87=8D/=E5=90=88=E8=A7=84?=
=?UTF-8?q?=E7=A1=AC=E9=97=B8/=E5=B0=81=E9=9D=A2=E5=85=9C=E5=BA=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../mate/content/model/ContentItemEntity.java | 62 +++++
.../content/repository/ContentItemMapper.java | 13 ++
.../mate/system/service/SettingCrypto.java | 127 ++++++++++
.../system/service/SystemSettingService.java | 25 +-
.../mate/tool/builtin/ComplianceScanTool.java | 33 +++
.../mate/tool/builtin/ComplianceScanner.java | 101 ++++++++
.../mate/tool/builtin/ContentItemTool.java | 168 ++++++++++++++
.../vip/mate/tool/builtin/GzhPackageTool.java | 59 ++++-
.../vip/mate/tool/builtin/GzhPublishTool.java | 216 +++++++++++++++---
.../tool/builtin/WxMpServiceProvider.java | 120 ++++++++++
.../src/main/resources/db/data-en.sql | 8 +
.../main/resources/db/data-kingbase-en.sql | 8 +
.../main/resources/db/data-kingbase-zh.sql | 8 +
.../src/main/resources/db/data-mysql-en.sql | 8 +
.../src/main/resources/db/data-mysql-zh.sql | 8 +
.../src/main/resources/db/data-zh.sql | 8 +
.../h2/V169__content_item_ledger.sql | 28 +++
.../kingbase/V169__content_item_ledger.sql | 27 +++
.../mysql/V169__content_item_ledger.sql | 27 +++
.../resources/skills/gzh_article/SKILL.md | 10 +-
.../main/resources/skills/xhs_note/SKILL.md | 10 +-
.../system/service/SettingCryptoTest.java | 58 +++++
.../service/SystemSettingBoolApiTest.java | 3 +-
.../SystemSettingServiceCatalogTest.java | 10 +-
.../tool/builtin/ComplianceScannerTest.java | 53 +++++
.../tool/builtin/ContentItemToolTest.java | 96 ++++++++
.../builtin/GzhPackageCoverHealingTest.java | 15 +-
.../builtin/GzhPublishImageInlineTest.java | 90 ++++++++
28 files changed, 1348 insertions(+), 51 deletions(-)
create mode 100644 mateclaw-server/src/main/java/vip/mate/content/model/ContentItemEntity.java
create mode 100644 mateclaw-server/src/main/java/vip/mate/content/repository/ContentItemMapper.java
create mode 100644 mateclaw-server/src/main/java/vip/mate/system/service/SettingCrypto.java
create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/builtin/ComplianceScanTool.java
create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/builtin/ComplianceScanner.java
create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/builtin/ContentItemTool.java
create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/builtin/WxMpServiceProvider.java
create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V169__content_item_ledger.sql
create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V169__content_item_ledger.sql
create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V169__content_item_ledger.sql
create mode 100644 mateclaw-server/src/test/java/vip/mate/system/service/SettingCryptoTest.java
create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/builtin/ComplianceScannerTest.java
create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/builtin/ContentItemToolTest.java
create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/builtin/GzhPublishImageInlineTest.java
diff --git a/mateclaw-server/src/main/java/vip/mate/content/model/ContentItemEntity.java b/mateclaw-server/src/main/java/vip/mate/content/model/ContentItemEntity.java
new file mode 100644
index 00000000..f9c5e80a
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/content/model/ContentItemEntity.java
@@ -0,0 +1,62 @@
+package vip.mate.content.model;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * A produced content item (公众号 article / 小红书 note) tracked across its
+ * lifecycle. Backs the content calendar so the daily scheduler can avoid
+ * repeating topics and so publishing is idempotent and auditable.
+ *
+ *
{@code topicFingerprint} is a stable hash of the normalized topic; it is the
+ * dedup key for "did we already cover this recently". {@code status} moves
+ * {@code draft/packaged → published} (or {@code failed}).
+ */
+@Data
+@TableName("mate_content_item")
+public class ContentItemEntity {
+
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ /** Owning workspace; nullable for single-user setups. */
+ private Long workspaceId;
+
+ /** Target platform: {@code gzh} (公众号) or {@code xhs} (小红书). */
+ private String platform;
+
+ /** The chosen topic, human-readable. */
+ private String topic;
+
+ /** Stable hash of the normalized topic — the recency/dedup key. */
+ private String topicFingerprint;
+
+ /** Final title of the produced piece. */
+ private String title;
+
+ /** Lifecycle: {@code draft} | {@code packaged} | {@code published} | {@code failed}. */
+ private String status;
+
+ /** Platform-side reference: draft media_id / publish_id, when applicable. */
+ private String externalRef;
+
+ /** Online-preview link handed to the user. */
+ private String previewUrl;
+
+ @TableField(fill = FieldFill.INSERT)
+ private LocalDateTime createTime;
+
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ private LocalDateTime updateTime;
+
+ /** Set when the item is marked published. */
+ private LocalDateTime publishTime;
+
+ private Integer deleted;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/content/repository/ContentItemMapper.java b/mateclaw-server/src/main/java/vip/mate/content/repository/ContentItemMapper.java
new file mode 100644
index 00000000..b9bc6829
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/content/repository/ContentItemMapper.java
@@ -0,0 +1,13 @@
+package vip.mate.content.repository;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import vip.mate.content.model.ContentItemEntity;
+
+/**
+ * Mapper for {@link ContentItemEntity}. Must live under a {@code repository}
+ * package so {@code @MapperScan("vip.mate.**.repository")} registers it.
+ */
+@Mapper
+public interface ContentItemMapper extends BaseMapper {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SettingCrypto.java b/mateclaw-server/src/main/java/vip/mate/system/service/SettingCrypto.java
new file mode 100644
index 00000000..e948bd70
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/system/service/SettingCrypto.java
@@ -0,0 +1,127 @@
+package vip.mate.system.service;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.util.Base64;
+
+/**
+ * Transparent at-rest encryption for sensitive system settings (API keys,
+ * WeChat Official Account app secret, etc.). Values are encrypted with
+ * AES-256-GCM and stored as {@code enc:v1:}.
+ *
+ * Backward compatibility: {@link #decrypt} returns any value WITHOUT the
+ * {@code enc:v1:} prefix verbatim, so legacy plaintext secrets keep working and
+ * are transparently upgraded to ciphertext the next time they are saved.
+ *
+ *
Key source, in order:
+ *
+ * - {@code MATECLAW_SETTING_KEY} environment variable (any string — hashed
+ * to a 256-bit key). This is the recommended production setup; back it up,
+ * because rotating or losing it makes existing ciphertext unreadable.
+ * - A built-in default passphrase when the env var is absent. This still
+ * keeps secrets out of plaintext in the database, but since the passphrase
+ * ships with the code it is obfuscation rather than strong protection — a
+ * warning is logged at startup urging the operator to set the env var.
+ *
+ */
+@Slf4j
+@Component
+public class SettingCrypto {
+
+ /** Version-tagged prefix so the format can evolve and be detected on read. */
+ static final String PREFIX = "enc:v1:";
+ private static final String ENV_KEY = "MATECLAW_SETTING_KEY";
+ private static final int GCM_IV_BYTES = 12;
+ private static final int GCM_TAG_BITS = 128;
+ /** Fallback passphrase used only when the env var is unset (obfuscation-grade). */
+ private static final String DEFAULT_PASSPHRASE = "mateclaw-default-setting-key-v1";
+
+ private final SecretKeySpec key;
+ private final SecureRandom random = new SecureRandom();
+
+ public SettingCrypto(@Value("${mateclaw.setting.key:}") String configuredKey) {
+ String source = firstNonBlank(configuredKey, System.getenv(ENV_KEY));
+ if (source == null || source.isBlank()) {
+ log.warn("[SettingCrypto] No {} set — encrypting sensitive settings with a built-in "
+ + "default key (obfuscation only). Set {} to a strong secret in production "
+ + "and back it up; losing it makes stored secrets unreadable.", ENV_KEY, ENV_KEY);
+ source = DEFAULT_PASSPHRASE;
+ }
+ this.key = deriveKey(source);
+ }
+
+ /** Encrypt a plaintext value into the {@code enc:v1:} envelope. Blank in → blank out. */
+ public String encrypt(String plaintext) {
+ if (plaintext == null || plaintext.isEmpty()) {
+ return plaintext;
+ }
+ try {
+ byte[] iv = new byte[GCM_IV_BYTES];
+ random.nextBytes(iv);
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
+ byte[] ct = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
+ byte[] out = new byte[iv.length + ct.length];
+ System.arraycopy(iv, 0, out, 0, iv.length);
+ System.arraycopy(ct, 0, out, iv.length, ct.length);
+ return PREFIX + Base64.getEncoder().encodeToString(out);
+ } catch (Exception e) {
+ // Never persist a half-encrypted value; surface loudly instead.
+ throw new IllegalStateException("Failed to encrypt sensitive setting", e);
+ }
+ }
+
+ /**
+ * Decrypt an {@code enc:v1:} value. Any value without the prefix is returned
+ * unchanged (legacy plaintext), so reads never break during migration.
+ */
+ public String decrypt(String stored) {
+ if (stored == null || !stored.startsWith(PREFIX)) {
+ return stored;
+ }
+ try {
+ byte[] blob = Base64.getDecoder().decode(stored.substring(PREFIX.length()));
+ byte[] iv = new byte[GCM_IV_BYTES];
+ System.arraycopy(blob, 0, iv, 0, GCM_IV_BYTES);
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
+ byte[] pt = cipher.doFinal(blob, GCM_IV_BYTES, blob.length - GCM_IV_BYTES);
+ return new String(pt, StandardCharsets.UTF_8);
+ } catch (Exception e) {
+ // Wrong key or corrupt data — don't hand back ciphertext as if it were the secret.
+ log.error("[SettingCrypto] Failed to decrypt a sensitive setting (wrong {} or corrupt "
+ + "value?). Returning empty.", ENV_KEY);
+ return "";
+ }
+ }
+
+ /** True if the value is already in the encrypted envelope. */
+ public boolean isEncrypted(String value) {
+ return value != null && value.startsWith(PREFIX);
+ }
+
+ private static SecretKeySpec deriveKey(String source) {
+ try {
+ byte[] hash = MessageDigest.getInstance("SHA-256")
+ .digest(source.getBytes(StandardCharsets.UTF_8));
+ return new SecretKeySpec(hash, "AES");
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to derive setting encryption key", e);
+ }
+ }
+
+ private static String firstNonBlank(String a, String b) {
+ if (a != null && !a.isBlank()) {
+ return a;
+ }
+ return b;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java
index 589d11eb..ce85061c 100644
--- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java
+++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java
@@ -13,6 +13,7 @@ import vip.mate.tool.search.SearchProvider;
import vip.mate.tool.search.SearchProviderRegistry;
import java.util.List;
+import java.util.Set;
@Service
public class SystemSettingService {
@@ -86,8 +87,19 @@ public class SystemSettingService {
private static final String MINIMAX_API_KEY_KEY = "minimaxApiKey";
private static final String MINIMAX_REGION_KEY = "minimaxRegion";
+ /**
+ * Keys whose values are secrets and must be encrypted at rest. Reads decrypt
+ * transparently and writes encrypt; legacy plaintext is upgraded on next save
+ * (see {@link SettingCrypto}). Add every credential-bearing key here.
+ */
+ private static final Set SENSITIVE_KEYS = Set.of(
+ SERPER_API_KEY_KEY, TAVILY_API_KEY_KEY, WEIXINOA_APP_SECRET_KEY,
+ ZHIPU_API_KEY_KEY, FAL_API_KEY_KEY, KLING_ACCESS_KEY_KEY, KLING_SECRET_KEY_KEY,
+ RUNWAY_API_KEY_KEY, MINIMAX_API_KEY_KEY);
+
private final SystemSettingMapper systemSettingMapper;
private final SearchProviderRegistry searchProviderRegistry;
+ private final SettingCrypto settingCrypto;
/**
* {@code PluginManager} is injected lazily because the bean graph is
@@ -105,9 +117,11 @@ public class SystemSettingService {
public SystemSettingService(SystemSettingMapper systemSettingMapper,
SearchProviderRegistry searchProviderRegistry,
+ SettingCrypto settingCrypto,
@Lazy PluginManager pluginManager) {
this.systemSettingMapper = systemSettingMapper;
this.searchProviderRegistry = searchProviderRegistry;
+ this.settingCrypto = settingCrypto;
this.pluginManager = pluginManager;
}
@@ -515,7 +529,12 @@ public class SystemSettingService {
SystemSettingEntity entity = systemSettingMapper.selectOne(new LambdaQueryWrapper()
.eq(SystemSettingEntity::getSettingKey, key)
.last("LIMIT 1"));
- return entity != null && entity.getSettingValue() != null ? entity.getSettingValue() : defaultValue;
+ if (entity == null || entity.getSettingValue() == null) {
+ return defaultValue;
+ }
+ String stored = entity.getSettingValue();
+ // Sensitive keys are stored encrypted; decrypt() passes legacy plaintext through.
+ return SENSITIVE_KEYS.contains(key) ? settingCrypto.decrypt(stored) : stored;
}
private String maskApiKey(String apiKey) {
@@ -529,6 +548,10 @@ public class SystemSettingService {
}
private void saveValue(String key, String value, String description) {
+ // Encrypt secrets at rest; non-blank only (blank passes through to clear).
+ if (SENSITIVE_KEYS.contains(key) && value != null && !value.isEmpty()) {
+ value = settingCrypto.encrypt(value);
+ }
SystemSettingEntity entity = systemSettingMapper.selectOne(new LambdaQueryWrapper()
.eq(SystemSettingEntity::getSettingKey, key)
.last("LIMIT 1"));
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ComplianceScanTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ComplianceScanTool.java
new file mode 100644
index 00000000..3c69677c
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ComplianceScanTool.java
@@ -0,0 +1,33 @@
+package vip.mate.tool.builtin;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.ai.tool.annotation.ToolParam;
+import org.springframework.stereotype.Component;
+
+/**
+ * Built-in tool: server-side compliance scan for 公众号 / 小红书 copy. Deterministic
+ * backstop for the model's skill-side self-check — catches 广告法 极限词, WeChat 诱导
+ * words, 承诺收益/效果 and 医疗功效 claims. Run it before packaging or publishing.
+ */
+@Slf4j
+@Component
+public class ComplianceScanTool {
+
+ @Tool(name = "compliance_scan", description = """
+ Scan 公众号/小红书 copy (title + body) for policy violations before publishing:
+ 广告法 极限词 (最/第一/唯一/国家级/100%…), WeChat 诱导 words (集赞/助力/分享解锁/
+ 关注才能看…), 承诺收益/效果 (保本/稳赚/包过…), and 医疗功效 (治愈/抗癌…).
+
+ Returns a report listing each hit by category and whether it's high-risk.
+ High-risk hits (极限词 / 诱导 / 承诺收益) should be replaced before publishing —
+ the 公众号 publish path hard-blocks a mass-send on them.
+ """)
+ public String compliance_scan(
+ @ToolParam(description = "Text to scan (title + body)")
+ String text) {
+ ComplianceScanner.Result result = ComplianceScanner.scan(text);
+ log.info("[ComplianceScan] hits={}, highRisk={}", result.hits().size(), result.hasHighRisk());
+ return ComplianceScanner.report(result);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ComplianceScanner.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ComplianceScanner.java
new file mode 100644
index 00000000..9d0bbc45
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ComplianceScanner.java
@@ -0,0 +1,101 @@
+package vip.mate.tool.builtin;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Server-side hard compliance scan for content bound for 公众号 / 小红书.
+ * Model-side self-checks (skills) can be skipped or hallucinated; this is a
+ * deterministic backstop that publish paths can enforce.
+ *
+ * Four categories, roughly ordered by account risk:
+ *
+ * - {@code 广告法极限词} — 绝对化用语(最/第一/唯一/国家级/100%…)
+ * - {@code 微信诱导} — 诱导分享/关注(集赞/助力/分享解锁/关注才能看…)— WeChat's most account-fatal rule
+ * - {@code 承诺收益/效果} — 保本/稳赚/包过/根治…
+ * - {@code 医疗功效} — 治愈/抗癌/包瘦/排毒…
+ *
+ * The first three are treated as high-risk (a publish path may block on them).
+ */
+final class ComplianceScanner {
+
+ private ComplianceScanner() {
+ }
+
+ /** Category name → matching pattern. Ordered by severity for stable output. */
+ private static final Map RULES = new LinkedHashMap<>();
+
+ /** Categories that a publish path should hard-block on. */
+ private static final List HIGH_RISK = List.of("广告法极限词", "微信诱导", "承诺收益/效果");
+
+ static {
+ RULES.put("广告法极限词", Pattern.compile(
+ "最佳|最好|最优|最强|最高级|最便宜|最先进|最顶级|第一品牌|全国第一|全球第一"
+ + "|唯一|独家|首个|首选|冠军|领导品牌|国家级|世界级|国际级|顶级|极致"
+ + "|100%|百分百|绝对|彻底根治|永久|包治|一劳永逸"));
+ RULES.put("微信诱导", Pattern.compile(
+ "集赞|助力|砍一刀|分享到朋友圈|分享后解锁|分享解锁|分享可见|转发抽奖|转发领取"
+ + "|不转不是|关注才能看|关注才可见|关注领取|关注解锁|扫码加个人微信|加我微信领"));
+ RULES.put("承诺收益/效果", Pattern.compile(
+ "保本|稳赚|稳赚不赔|保收益|保底收益|包赚|躺赚|一夜暴富"
+ + "|包过|保过|保分|名校保录|包录取|包就业|包瘦身"));
+ RULES.put("医疗功效", Pattern.compile(
+ "治愈|根治|抗癌|防癌|包瘦|排毒|壮阳|丰胸|生发防脱|药到病除|无副作用"));
+ }
+
+ /** One category's hits. */
+ record CategoryHit(String category, List terms, boolean highRisk) {}
+
+ /** Full scan result. */
+ record Result(List hits) {
+ boolean clean() {
+ return hits.isEmpty();
+ }
+
+ boolean hasHighRisk() {
+ return hits.stream().anyMatch(CategoryHit::highRisk);
+ }
+ }
+
+ /** Scan text for policy violations across all categories. */
+ static Result scan(String text) {
+ List hits = new ArrayList<>();
+ if (text == null || text.isBlank()) {
+ return new Result(hits);
+ }
+ for (Map.Entry rule : RULES.entrySet()) {
+ List terms = new ArrayList<>();
+ Matcher m = rule.getValue().matcher(text);
+ while (m.find()) {
+ String term = m.group();
+ if (!terms.contains(term)) {
+ terms.add(term);
+ }
+ }
+ if (!terms.isEmpty()) {
+ hits.add(new CategoryHit(rule.getKey(), terms, HIGH_RISK.contains(rule.getKey())));
+ }
+ }
+ return new Result(hits);
+ }
+
+ /** Render a scan result as a short Chinese report. */
+ static String report(Result result) {
+ if (result.clean()) {
+ return "✅ 合规扫描:未命中极限词 / 诱导词 / 承诺收益 / 功效违禁词。";
+ }
+ StringBuilder sb = new StringBuilder("⚠️ 合规扫描命中:\n");
+ for (CategoryHit h : result.hits()) {
+ sb.append("- [").append(h.category()).append(h.highRisk() ? " · 高危" : "")
+ .append("] ").append(String.join("、", h.terms())).append('\n');
+ }
+ sb.append(result.hasHighRisk()
+ ? "含高危词,发布前必须替换(尤其微信诱导词,易限流/封号)。"
+ : "建议替换后再发布。");
+ return sb.toString();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ContentItemTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ContentItemTool.java
new file mode 100644
index 00000000..37ce9725
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ContentItemTool.java
@@ -0,0 +1,168 @@
+package vip.mate.tool.builtin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.ai.tool.annotation.ToolParam;
+import org.springframework.stereotype.Component;
+import vip.mate.content.model.ContentItemEntity;
+import vip.mate.content.repository.ContentItemMapper;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * Built-in tool: the content calendar / dedup ledger for the content studio.
+ * Lets the daily scheduler avoid repeating a topic, records produced pieces, and
+ * marks them published so publishing is idempotent and auditable.
+ *
+ * Actions:
+ *
+ * - {@code check_recent} — has this topic been produced on this platform in
+ * the last N days? Call BEFORE committing to a topic.
+ * - {@code record} — log a produced piece (draft/packaged) with its title and
+ * preview link.
+ * - {@code mark_published} — flip an item to published with its platform ref.
+ *
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ContentItemTool {
+
+ private static final int DEFAULT_RECENT_DAYS = 14;
+
+ private final ContentItemMapper contentItemMapper;
+
+ @Tool(name = "content_item", description = """
+ Content calendar / dedup ledger for 公众号 & 小红书 pieces.
+
+ Actions:
+ - check_recent: has `topic` already been produced for `platform`
+ (gzh|xhs) within the last `days` (default 14)? Call this BEFORE picking
+ a topic in a scheduled run, to avoid repeats. Returns whether it's a
+ repeat plus recent titles.
+ - record: log a produced piece — platform, topic, title, status
+ (draft|packaged|published, default packaged), optional previewUrl /
+ externalRef. Returns the item id.
+ - mark_published: set item `id` to published with optional externalRef
+ (draft media_id / publish id).
+ """)
+ public String content_item(
+ @ToolParam(description = "Action: check_recent | record | mark_published")
+ String action,
+ @ToolParam(description = "Platform: gzh (公众号) or xhs (小红书)", required = false)
+ String platform,
+ @ToolParam(description = "Topic text (check_recent / record)", required = false)
+ String topic,
+ @ToolParam(description = "Title of the produced piece (record)", required = false)
+ String title,
+ @ToolParam(description = "Lifecycle status for record: draft|packaged|published", required = false)
+ String status,
+ @ToolParam(description = "Online preview link (record)", required = false)
+ String previewUrl,
+ @ToolParam(description = "Platform ref — draft media_id / publish id (record / mark_published)", required = false)
+ String externalRef,
+ @ToolParam(description = "Recency window in days for check_recent (default 14)", required = false)
+ Integer days,
+ @ToolParam(description = "Item id (mark_published)", required = false)
+ Long id) {
+
+ String act = action == null ? "" : action.trim().toLowerCase();
+ return switch (act) {
+ case "check_recent" -> checkRecent(platform, topic, days);
+ case "record" -> record(platform, topic, title, status, previewUrl, externalRef);
+ case "mark_published" -> markPublished(id, externalRef);
+ default -> "Error: unknown action '" + act + "'. Use check_recent | record | mark_published.";
+ };
+ }
+
+ private String checkRecent(String platform, String topic, Integer days) {
+ if (isBlank(platform) || isBlank(topic)) {
+ return "Error: platform and topic are required for check_recent.";
+ }
+ int window = (days == null || days <= 0) ? DEFAULT_RECENT_DAYS : days;
+ LocalDateTime cutoff = LocalDateTime.now().minusDays(window);
+ String fp = fingerprint(topic);
+ List recent = contentItemMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(ContentItemEntity::getPlatform, platform.trim().toLowerCase())
+ .eq(ContentItemEntity::getTopicFingerprint, fp)
+ .ge(ContentItemEntity::getCreateTime, cutoff)
+ .orderByDesc(ContentItemEntity::getCreateTime));
+ if (recent.isEmpty()) {
+ return "✅ 未重复:最近 " + window + " 天没有在 " + platform + " 做过「" + topic + "」,可以继续。";
+ }
+ StringBuilder sb = new StringBuilder();
+ sb.append("⚠️ 疑似重复:最近 ").append(window).append(" 天已在 ").append(platform)
+ .append(" 做过同题「").append(topic).append("」").append(recent.size()).append(" 次:\n");
+ for (ContentItemEntity e : recent) {
+ sb.append("- ").append(e.getCreateTime() != null ? e.getCreateTime().toLocalDate() : "?")
+ .append("|").append(e.getTitle() != null ? e.getTitle() : "(无标题)")
+ .append("|").append(e.getStatus()).append('\n');
+ }
+ sb.append("建议换个角度或另选选题。");
+ return sb.toString();
+ }
+
+ private String record(String platform, String topic, String title, String status,
+ String previewUrl, String externalRef) {
+ if (isBlank(platform) || isBlank(topic)) {
+ return "Error: platform and topic are required for record.";
+ }
+ ContentItemEntity e = new ContentItemEntity();
+ e.setPlatform(platform.trim().toLowerCase());
+ e.setTopic(topic.trim());
+ e.setTopicFingerprint(fingerprint(topic));
+ e.setTitle(title != null ? title.trim() : null);
+ e.setStatus(isBlank(status) ? "packaged" : status.trim().toLowerCase());
+ e.setPreviewUrl(previewUrl);
+ e.setExternalRef(externalRef);
+ contentItemMapper.insert(e);
+ log.info("[ContentItem] recorded id={} platform={} status={} title='{}'",
+ e.getId(), e.getPlatform(), e.getStatus(), title);
+ return "✅ 已记入内容日历。item id: " + e.getId() + "(status=" + e.getStatus() + ")";
+ }
+
+ private String markPublished(Long id, String externalRef) {
+ if (id == null) {
+ return "Error: id is required for mark_published.";
+ }
+ ContentItemEntity e = contentItemMapper.selectById(id);
+ if (e == null) {
+ return "Error: content item " + id + " not found.";
+ }
+ e.setStatus("published");
+ e.setPublishTime(LocalDateTime.now());
+ if (!isBlank(externalRef)) {
+ e.setExternalRef(externalRef);
+ }
+ contentItemMapper.updateById(e);
+ log.info("[ContentItem] item {} marked published (ref={})", id, externalRef);
+ return "✅ 已标记为已发布。item id: " + id;
+ }
+
+ /** Stable 32-hex fingerprint of the normalized topic (lowercased, alnum/CJK only). */
+ static String fingerprint(String topic) {
+ String normalized = topic == null ? "" : topic.toLowerCase()
+ .replaceAll("[\\s\\p{Punct}\\u3000-\\u303F\\uFF00-\\uFFEF]+", "");
+ try {
+ byte[] hash = MessageDigest.getInstance("SHA-256")
+ .digest(normalized.getBytes(StandardCharsets.UTF_8));
+ StringBuilder hex = new StringBuilder();
+ for (int i = 0; i < 16; i++) {
+ hex.append(String.format("%02x", hash[i]));
+ }
+ return hex.toString();
+ } catch (Exception e) {
+ return Integer.toHexString(normalized.hashCode());
+ }
+ }
+
+ private static boolean isBlank(String s) {
+ return s == null || s.isBlank();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPackageTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPackageTool.java
index b4c924a9..22d07650 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPackageTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPackageTool.java
@@ -18,6 +18,12 @@ import org.springframework.stereotype.Component;
import vip.mate.tool.browser.UrlSafetyChecker;
import vip.mate.tool.document.GeneratedFileCache;
+import javax.imageio.ImageIO;
+import java.awt.Color;
+import java.awt.GradientPaint;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
@@ -107,10 +113,16 @@ public class GzhPackageTool {
// an actual image is dropped (and flagged) rather than rendered. This also
// self-heals a reference that points at the file's name instead of its id.
ResolvedCover cover = resolveCover(coverImageUrl, ctx);
- String coverTag = (cover != null)
- ? "
"
- : "";
+ // Fallback: 公众号 requires a cover to publish, so never ship without one —
+ // synthesize a neutral gradient placeholder when none resolves.
+ boolean placeholderCover = false;
+ if (cover == null) {
+ byte[] ph = placeholderCover();
+ cover = new ResolvedCover(ph, store(ph, "gzh-cover-placeholder.png", "image/png", ctx));
+ placeholderCover = true;
+ }
+ String coverTag = "
";
String meta = (author != null && !author.isBlank())
? "" + escapeText(author.trim()) + "
"
: "";
@@ -156,12 +168,13 @@ public class GzhPackageTool {
StringBuilder out = new StringBuilder();
out.append("✅ 公众号图文已打包完成。\n\n");
- if (coverImageUrl != null && !coverImageUrl.isBlank() && cover == null) {
- // Requested a cover but it didn't resolve to an image — say so instead
- // of silently shipping a broken image tag.
- out.append("⚠️ 提供的封面引用无法解析为图片,已跳过封面(未嵌坏图):")
- .append(coverImageUrl.trim())
- .append("\n 请改用 image_generate 返回的完整 URL(/api/v1/files/generated/)再打包一次。\n\n");
+ if (placeholderCover) {
+ // Never a broken image — but tell the user we substituted a placeholder.
+ boolean hadRef = coverImageUrl != null && !coverImageUrl.isBlank();
+ out.append("⚠️ ")
+ .append(hadRef ? "提供的封面无法解析为图片" : "未提供封面")
+ .append(",已生成占位封面(纯色渐变)。建议补一张正式头图(2.35:1),")
+ .append("用 image_generate(aspectRatio=landscape) 出图后把完整 URL 传给 coverImageUrl 再打包。\n\n");
}
out.append("🔍 在线预览(浏览器打开即渲染):").append(previewUrl).append('\n');
if (zipUrl != null) {
@@ -293,6 +306,32 @@ public class GzhPackageTool {
return null;
}
+ /**
+ * A neutral 2.35:1 gradient placeholder cover. Deliberately text-free — server
+ * JVMs often lack CJK fonts, so drawing the title risks tofu boxes; a clean
+ * gradient is a always-valid cover the user can replace with a real one.
+ */
+ private static byte[] placeholderCover() {
+ int w = 900, h = 383;
+ BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
+ Graphics2D g = img.createGraphics();
+ g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ g.setPaint(new GradientPaint(0, 0, new Color(0x2f6fed), w, h, new Color(0x1a3a8f)));
+ g.fillRect(0, 0, w, h);
+ // A couple of soft translucent circles for a bit of depth.
+ g.setColor(new Color(255, 255, 255, 26));
+ g.fillOval(w - 220, -120, 340, 340);
+ g.fillOval(-80, h - 160, 260, 260);
+ g.dispose();
+ try {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ ImageIO.write(img, "png", bos);
+ return bos.toByteArray();
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to render placeholder cover", e);
+ }
+ }
+
private static boolean isImage(GeneratedFileCache.Entry e) {
return e.mimeType() != null && e.mimeType().startsWith("image/");
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPublishTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPublishTool.java
index 0dffd458..da31b900 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPublishTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPublishTool.java
@@ -6,20 +6,27 @@ import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.common.api.WxConsts;
import me.chanjar.weixin.common.error.WxErrorException;
import me.chanjar.weixin.mp.api.WxMpService;
-import me.chanjar.weixin.mp.api.impl.WxMpServiceImpl;
import me.chanjar.weixin.mp.bean.draft.WxMpAddDraft;
import me.chanjar.weixin.mp.bean.draft.WxMpDraftArticles;
import me.chanjar.weixin.mp.bean.material.WxMpMaterial;
import me.chanjar.weixin.mp.bean.material.WxMpMaterialUploadResult;
-import me.chanjar.weixin.mp.config.impl.WxMpDefaultConfigImpl;
+import me.chanjar.weixin.mp.bean.material.WxMediaImgUploadResult;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import vip.mate.system.service.SystemSettingService;
+import vip.mate.tool.browser.UrlSafetyChecker;
+import vip.mate.tool.document.GeneratedFileCache;
import java.io.File;
import java.nio.file.Files;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Optional;
+import java.util.regex.Matcher;
/**
* Built-in tool: publish a generated 图文 article to a WeChat Official Account.
@@ -44,6 +51,8 @@ public class GzhPublishTool {
private static final String SETTING_APP_SECRET = "weixinoa.app_secret";
private final SystemSettingService systemSettingService;
+ private final WxMpServiceProvider wxMpServiceProvider;
+ private final GeneratedFileCache generatedFileCache;
@Tool(name = "gzh_publish", description = """
Publish a generated image-text article to a WeChat Official Account (微信公众号).
@@ -87,7 +96,7 @@ public class GzhPublishTool {
+ "' and '" + SETTING_APP_SECRET + "' in system settings first.";
}
- WxMpService wxMpService = buildService(appId, appSecret);
+ WxMpService wxMpService = wxMpServiceProvider.getService(appId, appSecret);
String act = (action == null || action.isBlank()) ? "draft" : action.trim().toLowerCase();
return switch (act) {
@@ -109,6 +118,16 @@ public class GzhPublishTool {
return "Error: coverImageUrl is required — WeChat needs a cover/thumb for the article.";
}
+ // Hard compliance gate (fail fast, before any upload): refuse to draft
+ // account-fatal copy — 广告法 极限词 / 微信诱导 / 承诺收益. Lower-risk hits
+ // (医疗功效) are surfaced as a warning on success instead.
+ ComplianceScanner.Result scan = ComplianceScanner.scan(
+ title + "\n" + content.replaceAll("<[^>]+>", " "));
+ if (scan.hasHighRisk()) {
+ return "⛔ 合规拦截:命中高危违规词,已阻止进入草稿箱,请替换后再发。\n"
+ + ComplianceScanner.report(scan);
+ }
+
// 1. Download the cover and upload it as a permanent image material -> thumb media_id.
String thumbMediaId;
File tmpCover = null;
@@ -118,15 +137,15 @@ public class GzhPublishTool {
WxMpMaterial material = new WxMpMaterial();
material.setName(tmpCover.getName());
material.setFile(tmpCover);
- WxMpMaterialUploadResult uploaded = wxMpService.getMaterialService()
- .materialFileUpload(WxConsts.MediaFileType.IMAGE, material);
+ WxMpMaterialUploadResult uploaded = withRetry(() -> wxMpService.getMaterialService()
+ .materialFileUpload(WxConsts.MediaFileType.IMAGE, material));
thumbMediaId = uploaded.getMediaId();
if (thumbMediaId == null || thumbMediaId.isBlank()) {
return "Error: cover upload returned no media_id.";
}
} catch (WxErrorException e) {
log.warn("[GzhPublish] cover upload failed: {}", e.getMessage());
- return "Error: cover upload failed — " + e.getMessage();
+ return "Error: 封面上传失败 — " + translateWxError(e);
} catch (Exception e) {
log.warn("[GzhPublish] cover download/upload failed: {}", e.getMessage());
return "Error: cover download/upload failed — " + e.getMessage();
@@ -137,11 +156,19 @@ public class GzhPublishTool {
}
}
- // 2. Build the draft article and submit it.
+ // 2. Inline body images into WeChat: article HTML with external
+ // (our generated-file URLs, localhost, any non-mp host) renders broken in
+ // the published article — WeChat only displays images it hosts. Upload each
+ // and rewrite src to the returned mp.weixin.qq.com URL. Failures don't block
+ // the draft; they're reported so the user can fix those images by hand.
+ ImageInlineResult inlined = inlineContentImages(wxMpService, content);
+ String bodyHtml = inlined.html();
+
+ // 3. Build the draft article and submit it.
try {
WxMpDraftArticles article = new WxMpDraftArticles();
article.setTitle(trimTo(title, 64));
- article.setContent(content);
+ article.setContent(bodyHtml);
article.setThumbMediaId(thumbMediaId);
if (author != null && !author.isBlank()) {
article.setAuthor(trimTo(author, 8));
@@ -150,17 +177,31 @@ public class GzhPublishTool {
? trimTo(digest, 120)
: deriveDigest(content));
- String draftMediaId = wxMpService.getDraftService()
- .addDraft(new WxMpAddDraft(List.of(article)));
- log.info("[GzhPublish] draft created, media_id={}, title='{}'", draftMediaId, title);
- return "✅ 已存入公众号草稿箱。\n"
- + "draft media_id: " + draftMediaId + "\n"
- + "请到公众号后台「草稿箱」核对排版后点击「发表」。\n"
- + "如需直接群发(仅认证号),可用 gzh_publish action=publish draftMediaId=" + draftMediaId
- + " confirmPublish=true,并在发布前与用户再次确认内容。";
+ String draftMediaId = withRetry(() -> wxMpService.getDraftService()
+ .addDraft(new WxMpAddDraft(List.of(article))));
+ log.info("[GzhPublish] draft created, media_id={}, title='{}', imagesInlined={}, imagesFailed={}",
+ draftMediaId, title, inlined.uploaded(), inlined.failed().size());
+ StringBuilder ok = new StringBuilder();
+ ok.append("✅ 已存入公众号草稿箱。\n");
+ ok.append("draft media_id: ").append(draftMediaId).append('\n');
+ if (inlined.uploaded() > 0) {
+ ok.append("正文图已上传微信并改写链接:").append(inlined.uploaded()).append(" 张。\n");
+ }
+ if (!inlined.failed().isEmpty()) {
+ ok.append("⚠️ 有 ").append(inlined.failed().size())
+ .append(" 张正文图未能上传(发布后会裂图,请在后台手动替换):")
+ .append(String.join(";", inlined.failed())).append('\n');
+ }
+ if (!scan.clean()) {
+ ok.append("⚠️ 合规提示(非高危,建议核对):").append(ComplianceScanner.report(scan)).append('\n');
+ }
+ ok.append("请到公众号后台「草稿箱」核对排版后点击「发表」。\n");
+ ok.append("如需直接群发(仅认证号),可用 gzh_publish action=publish draftMediaId=").append(draftMediaId)
+ .append(" confirmPublish=true,并在发布前与用户再次确认内容。");
+ return ok.toString();
} catch (WxErrorException e) {
log.warn("[GzhPublish] addDraft failed: {}", e.getMessage());
- return "Error: creating the draft failed — " + e.getMessage();
+ return "Error: 创建草稿失败 — " + translateWxError(e);
}
}
@@ -173,23 +214,146 @@ public class GzhPublishTool {
+ "then call again with confirmPublish=true.";
}
try {
- String publishId = wxMpService.getFreePublishService().submit(draftMediaId);
+ String publishId = withRetry(() -> wxMpService.getFreePublishService().submit(draftMediaId));
log.info("[GzhPublish] free-publish submitted, publish_id={}, draft={}", publishId, draftMediaId);
return "✅ 已提交群发(free-publish)。publish_id: " + publishId
+ "\n注意:发布结果由微信异步审核,请在公众号后台确认最终状态。";
} catch (WxErrorException e) {
log.warn("[GzhPublish] free-publish failed: {}", e.getMessage());
- return "Error: free-publish failed (verified accounts only) — " + e.getMessage();
+ return "Error: 群发失败(仅认证号可用)— " + translateWxError(e);
}
}
- private WxMpService buildService(String appId, String appSecret) {
- WxMpDefaultConfigImpl config = new WxMpDefaultConfigImpl();
- config.setAppId(appId);
- config.setSecret(appSecret);
- WxMpService service = new WxMpServiceImpl();
- service.setWxMpConfigStorage(config);
- return service;
+ @FunctionalInterface
+ private interface WxCall {
+ T get() throws WxErrorException;
+ }
+
+ /**
+ * Retry a WeChat call on transient error codes (system busy / rate limit) with
+ * a short backoff, up to 2 extra attempts. Non-transient errors (bad token,
+ * IP whitelist, unauthorized) throw immediately — retrying them is pointless.
+ */
+ private static T withRetry(WxCall call) throws WxErrorException {
+ int attempt = 0;
+ while (true) {
+ try {
+ return call.get();
+ } catch (WxErrorException e) {
+ int code = e.getError() != null ? e.getError().getErrorCode() : 0;
+ boolean transient_ = (code == -1 || code == 45009);
+ if (transient_ && attempt < 2) {
+ attempt++;
+ try {
+ Thread.sleep(500L * attempt);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ throw e;
+ }
+ continue;
+ }
+ throw e;
+ }
+ }
+ }
+
+ /** Translate a WeChat error into an actionable Chinese hint (falls back to the raw message). */
+ static String translateWxError(WxErrorException e) {
+ int code = e.getError() != null ? e.getError().getErrorCode() : 0;
+ String hint = switch (code) {
+ case 40164 -> "服务器公网 IP 不在公众号后台白名单。到「设置与开发 → 安全中心 / IP 白名单」把本机公网 IP 加进去后重试。";
+ case 48001 -> "接口未授权:该能力通常仅认证服务号可用。";
+ case 40001, 42001 -> "access_token 无效或已过期:请核对 AppID/AppSecret,或稍后重试。";
+ case 45009 -> "接口调用频率超限:请稍后再试。";
+ case -1 -> "微信系统繁忙:请稍后再试。";
+ default -> "";
+ };
+ return hint.isEmpty()
+ ? (e.getMessage() == null ? "微信接口错误" : e.getMessage())
+ : hint + "(errcode=" + code + ")";
+ }
+
+ /** Result of rewriting article body images to WeChat-hosted URLs. */
+ record ImageInlineResult(String html, int uploaded, List failed) {}
+
+ /**
+ * Upload every non-WeChat body image to the Official Account and rewrite its
+ * {@code src} to the returned {@code mp.weixin.qq.com} URL, so images actually
+ * render in the published article. Images already on {@code mp.weixin.qq.com}
+ * (or {@code data:} URIs) are left as-is. A single image failing to upload is
+ * recorded and skipped — it never blocks the whole draft.
+ */
+ ImageInlineResult inlineContentImages(WxMpService wxMpService, String html) {
+ if (html == null || html.isBlank()) {
+ return new ImageInlineResult(html, 0, List.of());
+ }
+ Document doc = Jsoup.parseBodyFragment(html);
+ doc.outputSettings().prettyPrint(false);
+ List failed = new ArrayList<>();
+ int uploaded = 0;
+ for (Element img : doc.select("img[src]")) {
+ String src = img.attr("src").trim();
+ if (src.isEmpty() || src.contains("mp.weixin.qq.com") || src.startsWith("data:")) {
+ continue;
+ }
+ File tmp = null;
+ try {
+ byte[] bytes = resolveImageBytes(src);
+ if (bytes == null || bytes.length == 0) {
+ failed.add(src);
+ continue;
+ }
+ tmp = Files.createTempFile("gzh_img_", "." + extOf(src)).toFile();
+ Files.write(tmp.toPath(), bytes);
+ WxMediaImgUploadResult result = wxMpService.getMaterialService().mediaImgUpload(tmp);
+ if (result != null && result.getUrl() != null && !result.getUrl().isBlank()) {
+ img.attr("src", result.getUrl());
+ uploaded++;
+ } else {
+ failed.add(src);
+ }
+ } catch (Exception e) {
+ log.warn("[GzhPublish] content image upload failed for {}: {}", src, e.getMessage());
+ failed.add(src);
+ } finally {
+ if (tmp != null) {
+ //noinspection ResultOfMethodCallIgnored
+ tmp.delete();
+ }
+ }
+ }
+ return new ImageInlineResult(doc.body().html(), uploaded, failed);
+ }
+
+ /** Resolve an article-body image ref to bytes: our generated files, or http(s). */
+ private byte[] resolveImageBytes(String src) {
+ try {
+ Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(src);
+ if (m.find()) {
+ Optional e = generatedFileCache.get(m.group(1));
+ return e.map(GeneratedFileCache.Entry::bytes).orElse(null);
+ }
+ if (src.startsWith("http://") || src.startsWith("https://")) {
+ UrlSafetyChecker.check(src);
+ byte[] b = HttpUtil.downloadBytes(src);
+ return (b != null && b.length > 0) ? b : null;
+ }
+ } catch (Exception e) {
+ log.debug("[GzhPublish] could not resolve body image {}: {}", src, e.toString());
+ }
+ return null;
+ }
+
+ private static String extOf(String url) {
+ String clean = url.split("[?#]")[0];
+ int dot = clean.lastIndexOf('.');
+ if (dot >= 0 && dot < clean.length() - 1) {
+ String ext = clean.substring(dot + 1).toLowerCase();
+ if (ext.matches("(png|jpg|jpeg|gif|webp)")) {
+ return ext.equals("jpeg") ? "jpg" : ext;
+ }
+ }
+ return "jpg";
}
/** Strip tags and clamp to a length for the article digest. */
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WxMpServiceProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WxMpServiceProvider.java
new file mode 100644
index 00000000..a8e1eafe
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WxMpServiceProvider.java
@@ -0,0 +1,120 @@
+package vip.mate.tool.builtin;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.mp.api.WxMpService;
+import me.chanjar.weixin.mp.api.impl.WxMpServiceImpl;
+import me.chanjar.weixin.mp.config.impl.WxMpDefaultConfigImpl;
+import org.springframework.stereotype.Component;
+import vip.mate.system.service.SystemSettingService;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * Shares one {@link WxMpService} per {@code appId} and persists its
+ * {@code access_token} across restarts.
+ *
+ * Why this exists: WeChat allows only ONE valid {@code access_token} per
+ * appId at a time and rate-limits token fetches — fetching a new one silently
+ * invalidates the previous. Building a fresh {@code WxMpServiceImpl} on every
+ * call (the old {@code GzhPublishTool} behaviour) meant every publish, and every
+ * process restart, re-fetched a token and could thrash a token shared with other
+ * callers. Here the service (and its in-memory token) is cached by appId, and the
+ * token is mirrored into system settings so a restart reuses the live token
+ * instead of fetching another. Changing the app secret transparently rebuilds the
+ * cached service.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class WxMpServiceProvider {
+
+ private final SystemSettingService settingService;
+ private final ConcurrentMap cache = new ConcurrentHashMap<>();
+
+ private record Holder(String secret, WxMpService service) {}
+
+ /** Get (or build) the shared service for this appId/secret pair. */
+ public WxMpService getService(String appId, String appSecret) {
+ Holder existing = cache.get(appId);
+ if (existing != null && existing.secret().equals(appSecret)) {
+ return existing.service();
+ }
+ WxMpService service = build(appId, appSecret);
+ cache.put(appId, new Holder(appSecret, service));
+ return service;
+ }
+
+ /** Drop the cached service for an appId (e.g. after a credential change). */
+ public void invalidate(String appId) {
+ cache.remove(appId);
+ }
+
+ private WxMpService build(String appId, String appSecret) {
+ DbTokenConfig config = new DbTokenConfig(appId, settingService);
+ config.setAppId(appId);
+ config.setSecret(appSecret);
+ config.loadPersistedToken();
+ WxMpService service = new WxMpServiceImpl();
+ service.setWxMpConfigStorage(config);
+ log.debug("[WxMpServiceProvider] built WxMpService for appId={}", appId);
+ return service;
+ }
+
+ /**
+ * Config storage that mirrors the access_token into system settings so it
+ * survives a JVM restart. Token keys are per-appId and short-lived, so they
+ * are stored as ordinary (non-encrypted) settings.
+ */
+ static final class DbTokenConfig extends WxMpDefaultConfigImpl {
+
+ private final String appId;
+ private final transient SystemSettingService settings;
+
+ DbTokenConfig(String appId, SystemSettingService settings) {
+ this.appId = appId;
+ this.settings = settings;
+ }
+
+ private String tokenKey() {
+ return "weixinoa.token." + appId;
+ }
+
+ private String expiresKey() {
+ return "weixinoa.token_expires." + appId;
+ }
+
+ /** Seed the in-memory token from a previously persisted, still-valid one. */
+ void loadPersistedToken() {
+ String token = settings.getString(tokenKey(), "");
+ String expires = settings.getString(expiresKey(), "");
+ if (token == null || token.isBlank() || expires == null || expires.isBlank()) {
+ return;
+ }
+ try {
+ long expiresAt = Long.parseLong(expires.trim());
+ if (expiresAt > System.currentTimeMillis()) {
+ setAccessToken(token);
+ setExpiresTime(expiresAt);
+ }
+ } catch (NumberFormatException ignore) {
+ // Corrupt persisted expiry — ignore and let the service fetch fresh.
+ }
+ }
+
+ @Override
+ public void updateAccessToken(String accessToken, int expiresInSeconds) {
+ super.updateAccessToken(accessToken, expiresInSeconds);
+ // Mirror the freshly minted token so a restart reuses it.
+ try {
+ settings.saveString(tokenKey(), accessToken, "WeChat OA access_token cache");
+ settings.saveString(expiresKey(), String.valueOf(getExpiresTime()),
+ "WeChat OA access_token expiry (epoch ms)");
+ } catch (Exception e) {
+ // Persistence is best-effort; the in-memory token still works this run.
+ log.debug("[WxMpServiceProvider] could not persist access_token for {}: {}", appId, e.toString());
+ }
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/resources/db/data-en.sql b/mateclaw-server/src/main/resources/db/data-en.sql
index 6db36b2c..fac1f982 100644
--- a/mateclaw-server/src/main/resources/db/data-en.sql
+++ b/mateclaw-server/src/main/resources/db/data-en.sql
@@ -1954,3 +1954,11 @@ VALUES (1000000634, 'ScreenshotTool', 'Console Screenshot', 'Capture a screensho
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000635, 'XhsPackageTool', 'Xiaohongshu Package', 'Package a Xiaohongshu (小红书) note into an image-first online preview (phone-style swipe: images up top, copy below) plus a material zip (numbered card images + copy.txt). Requires at least 3 vertical images (1 cover + >=2 content); refuses fewer. 小红书 has no publish API; never auto-uploads.', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0);
+
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000636, 'ContentItemTool', 'Content Calendar', 'Content calendar / dedup ledger: check_recent (has this topic run on this platform in the last N days — call before picking a topic), record (log a produced piece with title/preview/status), mark_published. Keeps the daily scheduler from repeating topics and makes publishing auditable.', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0);
+
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000637, 'ComplianceScanTool', 'Compliance Scan', 'Server-side compliance scan before publishing: 广告法 极限词, WeChat 诱导 words (集赞/助力/share-to-unlock/follow-to-read), promised returns, and medical-efficacy claims. Returns hits by category; the 公众号 draft path hard-blocks high-risk hits.', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0);
diff --git a/mateclaw-server/src/main/resources/db/data-kingbase-en.sql b/mateclaw-server/src/main/resources/db/data-kingbase-en.sql
index a7f2f1e9..0d661405 100644
--- a/mateclaw-server/src/main/resources/db/data-kingbase-en.sql
+++ b/mateclaw-server/src/main/resources/db/data-kingbase-en.sql
@@ -1879,3 +1879,11 @@ ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000635, 'XhsPackageTool', 'Xiaohongshu Package', 'Package a Xiaohongshu (小红书) note into an image-first online preview (phone-style swipe: images up top, copy below) plus a material zip (numbered card images + copy.txt). Requires at least 3 vertical images (1 cover + >=2 content); refuses fewer. 小红书 has no publish API; never auto-uploads.', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000636, 'ContentItemTool', 'Content Calendar', 'Content calendar / dedup ledger: check_recent (has this topic run on this platform in the last N days — call before picking a topic), record (log a produced piece with title/preview/status), mark_published. Keeps the daily scheduler from repeating topics and makes publishing auditable.', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0)
+ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000637, 'ComplianceScanTool', 'Compliance Scan', 'Server-side compliance scan before publishing: 广告法 极限词, WeChat 诱导 words (集赞/助力/share-to-unlock/follow-to-read), promised returns, and medical-efficacy claims. Returns hits by category; the 公众号 draft path hard-blocks high-risk hits.', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0)
+ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
diff --git a/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql b/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql
index dea6ce35..448517d8 100644
--- a/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql
+++ b/mateclaw-server/src/main/resources/db/data-kingbase-zh.sql
@@ -1876,3 +1876,11 @@ ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000635, 'XhsPackageTool', '小红书打包', '把小红书笔记打包成在线预览(手机版滑动预览,以图为主、文字辅助)+ 素材下载 zip(编号卡片图 + 文案.txt)。强制至少 3 张竖版图(1 封面 + ≥2 内容图),不足则拒绝打包。小红书无发布 API,不自动上传。', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000636, 'ContentItemTool', '内容日历', '内容日历 / 发布去重台账:check_recent 查最近 N 天某平台是否做过同题(选题前先查避免重复);record 记录产出(含标题/预览链接/状态);mark_published 标记为已发布。让每日定时不重复选题、发布可追溯。', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0)
+ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000637, 'ComplianceScanTool', '合规扫描', '发布前服务端硬扫合规风险:广告法极限词(最/第一/唯一/国家级/100%)、微信诱导词(集赞/助力/分享解锁/关注才能看)、承诺收益、医疗功效。返回命中清单;公众号进草稿箱前对高危词硬拦截。', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0)
+ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
diff --git a/mateclaw-server/src/main/resources/db/data-mysql-en.sql b/mateclaw-server/src/main/resources/db/data-mysql-en.sql
index 12452bf6..0ac90976 100644
--- a/mateclaw-server/src/main/resources/db/data-mysql-en.sql
+++ b/mateclaw-server/src/main/resources/db/data-mysql-en.sql
@@ -1995,3 +1995,11 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), de
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000635, 'XhsPackageTool', 'Xiaohongshu Package', 'Package a Xiaohongshu (小红书) note into an image-first online preview (phone-style swipe: images up top, copy below) plus a material zip (numbered card images + copy.txt). Requires at least 3 vertical images (1 cover + >=2 content); refuses fewer. 小红书 has no publish API; never auto-uploads.', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000636, 'ContentItemTool', 'Content Calendar', 'Content calendar / dedup ledger: check_recent (has this topic run on this platform in the last N days — call before picking a topic), record (log a produced piece with title/preview/status), mark_published. Keeps the daily scheduler from repeating topics and makes publishing auditable.', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000637, 'ComplianceScanTool', 'Compliance Scan', 'Server-side compliance scan before publishing: 广告法 极限词, WeChat 诱导 words (集赞/助力/share-to-unlock/follow-to-read), promised returns, and medical-efficacy claims. Returns hits by category; the 公众号 draft path hard-blocks high-risk hits.', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
diff --git a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql
index b34d5bbe..7fa29f9a 100644
--- a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql
+++ b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql
@@ -1992,3 +1992,11 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), de
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000635, 'XhsPackageTool', '小红书打包', '把小红书笔记打包成在线预览(手机版滑动预览,以图为主、文字辅助)+ 素材下载 zip(编号卡片图 + 文案.txt)。强制至少 3 张竖版图(1 封面 + ≥2 内容图),不足则拒绝打包。小红书无发布 API,不自动上传。', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000636, 'ContentItemTool', '内容日历', '内容日历 / 发布去重台账:check_recent 查最近 N 天某平台是否做过同题(选题前先查避免重复);record 记录产出(含标题/预览链接/状态);mark_published 标记为已发布。让每日定时不重复选题、发布可追溯。', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000637, 'ComplianceScanTool', '合规扫描', '发布前服务端硬扫合规风险:广告法极限词(最/第一/唯一/国家级/100%)、微信诱导词(集赞/助力/分享解锁/关注才能看)、承诺收益、医疗功效。返回命中清单;公众号进草稿箱前对高危词硬拦截。', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
diff --git a/mateclaw-server/src/main/resources/db/data-zh.sql b/mateclaw-server/src/main/resources/db/data-zh.sql
index 6cfef638..767ac254 100644
--- a/mateclaw-server/src/main/resources/db/data-zh.sql
+++ b/mateclaw-server/src/main/resources/db/data-zh.sql
@@ -1955,3 +1955,11 @@ VALUES (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000635, 'XhsPackageTool', '小红书打包', '把小红书笔记打包成在线预览(手机版滑动预览,以图为主、文字辅助)+ 素材下载 zip(编号卡片图 + 文案.txt)。强制至少 3 张竖版图(1 封面 + ≥2 内容图),不足则拒绝打包。小红书无发布 API,不自动上传。', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0);
+
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000636, 'ContentItemTool', '内容日历', '内容日历 / 发布去重台账:check_recent 查最近 N 天某平台是否做过同题(选题前先查避免重复);record 记录产出(含标题/预览链接/状态);mark_published 标记为已发布。让每日定时不重复选题、发布可追溯。', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0);
+
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000637, 'ComplianceScanTool', '合规扫描', '发布前服务端硬扫合规风险:广告法极限词(最/第一/唯一/国家级/100%)、微信诱导词(集赞/助力/分享解锁/关注才能看)、承诺收益、医疗功效。返回命中清单;公众号进草稿箱前对高危词硬拦截。', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0);
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V169__content_item_ledger.sql b/mateclaw-server/src/main/resources/db/migration/h2/V169__content_item_ledger.sql
new file mode 100644
index 00000000..729558d5
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V169__content_item_ledger.sql
@@ -0,0 +1,28 @@
+-- V169: Content Studio production hardening — content calendar / dedup ledger
+-- (H2 dialect). Tracks每一篇产出的公众号/小红书内容,供每日调度避重与发布幂等。
+-- Also seeds the content_item tool row for existing databases.
+
+CREATE TABLE IF NOT EXISTS mate_content_item (
+ id BIGINT NOT NULL PRIMARY KEY,
+ workspace_id BIGINT NULL,
+ platform VARCHAR(16) NOT NULL,
+ topic VARCHAR(512),
+ topic_fingerprint VARCHAR(64),
+ title VARCHAR(256),
+ status VARCHAR(16),
+ external_ref VARCHAR(256),
+ preview_url VARCHAR(512),
+ create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
+ publish_time TIMESTAMP NULL,
+ deleted INT DEFAULT 0
+);
+CREATE INDEX IF NOT EXISTS idx_content_item_fp ON mate_content_item(platform, topic_fingerprint, create_time);
+
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000636, 'ContentItemTool', '内容日历', '内容日历 / 发布去重台账:check_recent 查最近 N 天某平台是否做过同题(选题前先查避免重复);record 记录产出(含标题/预览链接/状态);mark_published 标记为已发布。让每日定时不重复选题、发布可追溯。', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0);
+
+MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+KEY (id)
+VALUES (1000000637, 'ComplianceScanTool', '合规扫描', '发布前服务端硬扫合规风险:广告法极限词(最/第一/唯一/国家级/100%)、微信诱导词(集赞/助力/分享解锁/关注才能看)、承诺收益、医疗功效。返回命中清单;公众号进草稿箱前对高危词硬拦截。', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0);
diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V169__content_item_ledger.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V169__content_item_ledger.sql
new file mode 100644
index 00000000..a152e004
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V169__content_item_ledger.sql
@@ -0,0 +1,27 @@
+-- V169: Content Studio production hardening — content calendar / dedup ledger
+-- (KingbaseES / PostgreSQL dialect). See h2/V169 for design notes.
+
+CREATE TABLE IF NOT EXISTS mate_content_item (
+ id BIGINT NOT NULL PRIMARY KEY,
+ workspace_id BIGINT NULL,
+ platform VARCHAR(16) NOT NULL,
+ topic VARCHAR(512),
+ topic_fingerprint VARCHAR(64),
+ title VARCHAR(256),
+ status VARCHAR(16),
+ external_ref VARCHAR(256),
+ preview_url VARCHAR(512),
+ create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
+ publish_time TIMESTAMP NULL,
+ deleted INT DEFAULT 0
+);
+CREATE INDEX IF NOT EXISTS idx_content_item_fp ON mate_content_item(platform, topic_fingerprint, create_time);
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000636, 'ContentItemTool', '内容日历', '内容日历 / 发布去重台账:check_recent 查最近 N 天某平台是否做过同题(选题前先查避免重复);record 记录产出(含标题/预览链接/状态);mark_published 标记为已发布。让每日定时不重复选题、发布可追溯。', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0)
+ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000637, 'ComplianceScanTool', '合规扫描', '发布前服务端硬扫合规风险:广告法极限词(最/第一/唯一/国家级/100%)、微信诱导词(集赞/助力/分享解锁/关注才能看)、承诺收益、医疗功效。返回命中清单;公众号进草稿箱前对高危词硬拦截。', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0)
+ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V169__content_item_ledger.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V169__content_item_ledger.sql
new file mode 100644
index 00000000..d5a86073
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V169__content_item_ledger.sql
@@ -0,0 +1,27 @@
+-- V169: Content Studio production hardening — content calendar / dedup ledger
+-- (MySQL dialect). See h2/V169 for design notes.
+
+CREATE TABLE IF NOT EXISTS mate_content_item (
+ id BIGINT NOT NULL PRIMARY KEY,
+ workspace_id BIGINT NULL,
+ platform VARCHAR(16) NOT NULL,
+ topic VARCHAR(512),
+ topic_fingerprint VARCHAR(64),
+ title VARCHAR(256),
+ status VARCHAR(16),
+ external_ref VARCHAR(256),
+ preview_url VARCHAR(512),
+ create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ publish_time TIMESTAMP NULL,
+ deleted INT DEFAULT 0,
+ KEY idx_content_item_fp (platform, topic_fingerprint, create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000636, 'ContentItemTool', '内容日历', '内容日历 / 发布去重台账:check_recent 查最近 N 天某平台是否做过同题(选题前先查避免重复);record 记录产出(含标题/预览链接/状态);mark_published 标记为已发布。让每日定时不重复选题、发布可追溯。', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
+
+INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
+VALUES (1000000637, 'ComplianceScanTool', '合规扫描', '发布前服务端硬扫合规风险:广告法极限词(最/第一/唯一/国家级/100%)、微信诱导词(集赞/助力/分享解锁/关注才能看)、承诺收益、医疗功效。返回命中清单;公众号进草稿箱前对高危词硬拦截。', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0)
+ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
diff --git a/mateclaw-server/src/main/resources/skills/gzh_article/SKILL.md b/mateclaw-server/src/main/resources/skills/gzh_article/SKILL.md
index 587f461f..4720d4e5 100644
--- a/mateclaw-server/src/main/resources/skills/gzh_article/SKILL.md
+++ b/mateclaw-server/src/main/resources/skills/gzh_article/SKILL.md
@@ -1,7 +1,7 @@
---
name: gzh_article
description: '公众号图文创作 / 推文 / 官方号文章 (official account article) — 端到端:选题→搜集→成文→配图→去AI化→公众号内联样式排版→交付/草稿箱。honors user persona & style memory.'
-version: 1.1.0
+version: 1.2.0
tags:
- 公众号
- 图文
@@ -102,6 +102,14 @@ gzh_package(title="<标题>", markdown="<正文 Markdown,含小标题/列表/
`references/` 里的 `gzh_layout*.html` 是**服务端配色/排版的参考**(`gzh_package` 已内置同款风格);只有当用户明确要**高度定制的特殊版式**、且篇幅不大时,才手写内联 HTML 并用 `render_html_image(html=...)` 出预览图,注意控制体量避免截断。
+## 定时 / 批量场景:内容日历 + 合规硬闸
+
+长期投产(尤其每日定时)时,多接两步,避免重复选题和违规发布:
+
+- **选题前查重**:`content_item(action="check_recent", platform="gzh", topic="<选题>")`。命中"疑似重复"就换角度或另选。
+- **发布前硬扫**:把标题+正文交给 `compliance_scan` 服务端扫一遍(极限词/诱导/承诺/功效)。命中高危词先替换——`gzh_publish` 进草稿箱时也会对高危词硬拦截。
+- **产出落台账**:`gzh_package` / `gzh_publish` 完成后 `content_item(action="record", platform="gzh", title, previewUrl, status="packaged"|"draft")`;真正发表后 `content_item(action="mark_published", id, externalRef)`。让每日不重题、发布可追溯。
+
## 保存自定义模板 / 对话升级技能
当用户对某个自创模板满意、想以后复用时,用 `skill_manage` 把它**存成一个自定义技能**(`builtin=false` 才能写入):
diff --git a/mateclaw-server/src/main/resources/skills/xhs_note/SKILL.md b/mateclaw-server/src/main/resources/skills/xhs_note/SKILL.md
index dfa7d8d6..af43d3e1 100644
--- a/mateclaw-server/src/main/resources/skills/xhs_note/SKILL.md
+++ b/mateclaw-server/src/main/resources/skills/xhs_note/SKILL.md
@@ -1,7 +1,7 @@
---
name: xhs_note
description: '小红书图文创作 / 笔记 / 种草文案 (xiaohongshu / red note) — 端到端:成文→配图(≥3 张竖版)→去AI化→在线预览打包交付。以图为主、文字辅助:标题四件套 + 碎句正文 + 话题标签,配 3:4 竖版卡片,最少 3 张图。honors user persona & style memory.'
-version: 1.1.0
+version: 1.2.0
tags:
- 小红书
- 图文
@@ -116,6 +116,14 @@ xhs_package(title="<标题>", body="<正文,含 emoji 与换行>",
打包前对照 `banned_words` 扫一遍正文和标题,命中即标注替换。
+## 定时 / 批量场景:内容日历 + 合规
+
+长期投产(每日定时)时多接两步:
+
+- **选题前查重**:`content_item(action="check_recent", platform="xhs", topic="<选题>")`,命中就换角度。
+- **打包前硬扫**:把标题+正文交给 `compliance_scan`(极限词/诱导/承诺/功效),命中先替换。
+- **产出落台账**:`xhs_package` 完成后 `content_item(action="record", platform="xhs", title, previewUrl, status="packaged")`;用户手动上传发布后 `content_item(action="mark_published", id)`。这样能知道哪些已发、哪些还在待办。
+
## 保存自定义卡片模板 / 对话升级技能
用户满意某个自创卡片、想复用时,用 `skill_manage` 存成**自定义技能**(`builtin=false` 才能写):
diff --git a/mateclaw-server/src/test/java/vip/mate/system/service/SettingCryptoTest.java b/mateclaw-server/src/test/java/vip/mate/system/service/SettingCryptoTest.java
new file mode 100644
index 00000000..81639556
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/system/service/SettingCryptoTest.java
@@ -0,0 +1,58 @@
+package vip.mate.system.service;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Pin {@link SettingCrypto}: AES-GCM round-trips, ciphertext is prefixed and
+ * randomized per call, and legacy plaintext (no prefix) passes through so
+ * secrets stay readable during migration.
+ */
+class SettingCryptoTest {
+
+ private final SettingCrypto crypto = new SettingCrypto("unit-test-key");
+
+ @Test
+ @DisplayName("encrypt → decrypt round-trips, ciphertext is prefixed and differs from plaintext")
+ void roundTrip() {
+ String secret = "wx-app-secret-1234567890";
+ String enc = crypto.encrypt(secret);
+ assertTrue(enc.startsWith("enc:v1:"), "ciphertext must carry the version prefix");
+ assertNotEquals(secret, enc);
+ assertEquals(secret, crypto.decrypt(enc));
+ }
+
+ @Test
+ @DisplayName("legacy plaintext (no prefix) is returned unchanged")
+ void legacyPlaintextPassthrough() {
+ assertEquals("old-plain-secret", crypto.decrypt("old-plain-secret"));
+ }
+
+ @Test
+ @DisplayName("each encryption uses a fresh IV → different ciphertext, same plaintext")
+ void randomizedIv() {
+ String a = crypto.encrypt("same-value");
+ String b = crypto.encrypt("same-value");
+ assertNotEquals(a, b, "distinct IVs must yield distinct ciphertext");
+ assertEquals("same-value", crypto.decrypt(a));
+ assertEquals("same-value", crypto.decrypt(b));
+ }
+
+ @Test
+ @DisplayName("blank/null pass through untouched")
+ void blankPassthrough() {
+ assertEquals("", crypto.encrypt(""));
+ assertNull(crypto.encrypt(null));
+ assertNull(crypto.decrypt(null));
+ }
+
+ @Test
+ @DisplayName("wrong key cannot read another key's ciphertext")
+ void wrongKeyFailsClosed() {
+ String enc = crypto.encrypt("top-secret");
+ String recovered = new SettingCrypto("a-different-key").decrypt(enc);
+ assertEquals("", recovered, "a wrong key must not return the real secret");
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java
index a4c6f55c..0f3a78f7 100644
--- a/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingBoolApiTest.java
@@ -46,7 +46,8 @@ class SystemSettingBoolApiTest {
@BeforeEach
void setUp() {
- service = new SystemSettingService(mapper, new SearchProviderRegistry(List.of()), mock(PluginManager.class));
+ service = new SystemSettingService(mapper, new SearchProviderRegistry(List.of()),
+ new SettingCrypto("test-key"), mock(PluginManager.class));
}
private SystemSettingEntity row(String value) {
diff --git a/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingServiceCatalogTest.java b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingServiceCatalogTest.java
index 999b2d3f..18b004a0 100644
--- a/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingServiceCatalogTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/system/service/SystemSettingServiceCatalogTest.java
@@ -68,7 +68,7 @@ class SystemSettingServiceCatalogTest {
@DisplayName("marks builtin providers as builtin=true with no pluginName")
void builtinEntry() {
SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("serper", 300, true, false)));
- service = new SystemSettingService(mapper, registry, pluginManager);
+ service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager);
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
@@ -86,7 +86,7 @@ class SystemSettingServiceCatalogTest {
SearchProviderRegistry registry = new SearchProviderRegistry(List.of());
registry.registerPluginProvider(stub("my-search", 500, true, true));
when(pluginManager.getPluginNameForSearchProvider("my-search")).thenReturn("my-plugin");
- service = new SystemSettingService(mapper, registry, pluginManager);
+ service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager);
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
@@ -105,7 +105,7 @@ class SystemSettingServiceCatalogTest {
stub("duckduckgo", 100, false, true)));
registry.registerPluginProvider(stub("my-search", 200, true, true));
when(pluginManager.getPluginNameForSearchProvider("my-search")).thenReturn("my-plugin");
- service = new SystemSettingService(mapper, registry, pluginManager);
+ service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager);
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
@@ -128,7 +128,7 @@ class SystemSettingServiceCatalogTest {
@DisplayName("surfaces the resolved provider id and source alongside the catalog")
void resolvedSurfaced() {
SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("duckduckgo", 100, false, true)));
- service = new SystemSettingService(mapper, registry, pluginManager);
+ service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager);
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
@@ -140,7 +140,7 @@ class SystemSettingServiceCatalogTest {
@DisplayName("resolvedId/resolvedSource are null when no provider is available at all")
void resolvedNullWhenNothingAvailable() {
SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("serper", 300, true, false)));
- service = new SystemSettingService(mapper, registry, pluginManager);
+ service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager);
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/ComplianceScannerTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ComplianceScannerTest.java
new file mode 100644
index 00000000..cd6f48a3
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ComplianceScannerTest.java
@@ -0,0 +1,53 @@
+package vip.mate.tool.builtin;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Pin {@link ComplianceScanner}: high-risk categories (极限词 / 诱导 / 承诺收益) are
+ * flagged as high-risk so the publish path can hard-block them, medical-efficacy
+ * is a non-high-risk hit, and clean copy scans clean.
+ */
+class ComplianceScannerTest {
+
+ @Test
+ @DisplayName("广告法 极限词 → high-risk hit")
+ void adLawSuperlative() {
+ ComplianceScanner.Result r = ComplianceScanner.scan("我们是全国第一、效果最好的品牌");
+ assertFalse(r.clean());
+ assertTrue(r.hasHighRisk());
+ assertTrue(ComplianceScanner.report(r).contains("广告法极限词"));
+ }
+
+ @Test
+ @DisplayName("WeChat 诱导 words → high-risk hit")
+ void weChatInduce() {
+ ComplianceScanner.Result r = ComplianceScanner.scan("集赞 20 个送礼品,分享到朋友圈解锁全文");
+ assertTrue(r.hasHighRisk());
+ assertTrue(ComplianceScanner.report(r).contains("微信诱导"));
+ }
+
+ @Test
+ @DisplayName("promised returns → high-risk hit")
+ void promisedReturns() {
+ assertTrue(ComplianceScanner.scan("保本理财,稳赚不赔").hasHighRisk());
+ }
+
+ @Test
+ @DisplayName("medical efficacy → hit but NOT high-risk")
+ void medicalEfficacyNotHighRisk() {
+ ComplianceScanner.Result r = ComplianceScanner.scan("这款茶能排毒养颜");
+ assertFalse(r.clean());
+ assertFalse(r.hasHighRisk(), "医疗功效 is a warning, not a hard block");
+ }
+
+ @Test
+ @DisplayName("clean copy scans clean")
+ void cleanCopy() {
+ ComplianceScanner.Result r = ComplianceScanner.scan("这是我上周做的三道家常菜,步骤和用量都写清楚了。");
+ assertTrue(r.clean());
+ assertTrue(ComplianceScanner.report(r).contains("未命中"));
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/ContentItemToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ContentItemToolTest.java
new file mode 100644
index 00000000..bafdb255
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ContentItemToolTest.java
@@ -0,0 +1,96 @@
+package vip.mate.tool.builtin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import vip.mate.content.model.ContentItemEntity;
+import vip.mate.content.repository.ContentItemMapper;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * Pin {@link ContentItemTool}: the topic fingerprint is stable across cosmetic
+ * differences (so repeats are caught), and the check_recent / record /
+ * mark_published actions behave.
+ */
+class ContentItemToolTest {
+
+ private ContentItemMapper mapper;
+ private ContentItemTool tool;
+
+ @BeforeEach
+ void setUp() {
+ mapper = mock(ContentItemMapper.class);
+ tool = new ContentItemTool(mapper);
+ }
+
+ @Test
+ @DisplayName("fingerprint ignores case / whitespace / punctuation but distinguishes real topics")
+ void fingerprintStable() {
+ String a = ContentItemTool.fingerprint("周末咖啡探店");
+ String b = ContentItemTool.fingerprint(" 周末 咖啡,探店! ");
+ assertEquals(a, b, "cosmetic differences must collapse to the same fingerprint");
+ assertNotEquals(a, ContentItemTool.fingerprint("露营装备清单"), "different topics differ");
+ }
+
+ @Test
+ @DisplayName("check_recent: empty history → not a repeat")
+ void checkRecentEmpty() {
+ when(mapper.selectList(any())).thenReturn(List.of());
+ String out = tool.content_item("check_recent", "gzh", "周末咖啡探店",
+ null, null, null, null, 14, null);
+ assertTrue(out.contains("未重复"), out);
+ }
+
+ @Test
+ @DisplayName("check_recent: recent same-topic row → flagged as repeat with its title")
+ void checkRecentRepeat() {
+ ContentItemEntity prior = new ContentItemEntity();
+ prior.setTitle("上周那篇咖啡探店");
+ prior.setStatus("published");
+ prior.setCreateTime(LocalDateTime.now().minusDays(3));
+ when(mapper.selectList(any())).thenReturn(List.of(prior));
+
+ String out = tool.content_item("check_recent", "gzh", "周末咖啡探店",
+ null, null, null, null, 14, null);
+ assertTrue(out.contains("疑似重复"), out);
+ assertTrue(out.contains("上周那篇咖啡探店"), "should show the prior title");
+ }
+
+ @Test
+ @DisplayName("record: inserts a row and reports the item")
+ void recordInserts() {
+ String out = tool.content_item("record", "xhs", "露营装备清单",
+ "新手露营必带的8样东西", "packaged", "http://x/preview", null, null, null);
+ verify(mapper, times(1)).insert(any(ContentItemEntity.class));
+ assertTrue(out.contains("已记入内容日历"), out);
+ }
+
+ @Test
+ @DisplayName("mark_published: flips status and stamps publish time")
+ void markPublished() {
+ ContentItemEntity e = new ContentItemEntity();
+ e.setStatus("packaged");
+ when(mapper.selectById(123L)).thenReturn(e);
+
+ String out = tool.content_item("mark_published", null, null, null, null,
+ null, "media_abc", null, 123L);
+ assertTrue(out.contains("已标记为已发布"), out);
+ assertEquals("published", e.getStatus());
+ assertNotNull(e.getPublishTime());
+ verify(mapper).updateById(e);
+ }
+
+ @Test
+ @DisplayName("unknown action is rejected")
+ void unknownAction() {
+ assertTrue(tool.content_item("frobnicate", null, null, null, null, null, null, null, null)
+ .startsWith("Error:"));
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GzhPackageCoverHealingTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GzhPackageCoverHealingTest.java
index 08b5a1cb..599df782 100644
--- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GzhPackageCoverHealingTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GzhPackageCoverHealingTest.java
@@ -53,8 +53,8 @@ class GzhPackageCoverHealingTest {
}
@Test
- @DisplayName("unresolvable cover → dropped with a warning, never a broken
")
- void unresolvableCoverDroppedAndWarned() {
+ @DisplayName("unresolvable cover → placeholder cover + warning, never a broken
")
+ void unresolvableCoverUsesPlaceholder() {
String out = tool.gzh_package(
"标题",
BODY,
@@ -63,7 +63,9 @@ class GzhPackageCoverHealingTest {
null);
assertTrue(out.contains("⚠️"), "an unresolved cover must be flagged; got:\n" + out);
- assertFalse(out.contains("
";
+ WxMpService wx = wxReturning("http://mmbiz.qpic.cn/mmbiz_png/abc/0");
+
+ GzhPublishTool.ImageInlineResult r = tool.inlineContentImages(wx, html);
+
+ assertEquals(1, r.uploaded());
+ assertTrue(r.failed().isEmpty());
+ assertTrue(r.html().contains("http://mmbiz.qpic.cn/mmbiz_png/abc/0"), "src must be rewritten");
+ assertFalse(r.html().contains("/api/v1/files/generated/"), "the external ref must be gone");
+ }
+
+ @Test
+ @DisplayName("an image already on mp.weixin.qq.com is left untouched and not re-uploaded")
+ void leavesWeChatImageAlone() throws Exception {
+ String html = "
";
+ WxMpService wx = wxReturning("http://mmbiz.qpic.cn/should-not-be-used");
+
+ GzhPublishTool.ImageInlineResult r = tool.inlineContentImages(wx, html);
+
+ assertEquals(0, r.uploaded());
+ assertTrue(r.failed().isEmpty());
+ assertTrue(r.html().contains("mp.weixin.qq.com/existing.png"));
+ verify(wx, never()).getMaterialService();
+ }
+
+ @Test
+ @DisplayName("an unresolvable body image is reported as failed but does not block the rest")
+ void unresolvableImageReportedNotBlocking() throws Exception {
+ String good = cache.put("PNGDATA".getBytes(), "ok.png", "image/png");
+ String html = "
"
+ + "
";
+ WxMpService wx = wxReturning("http://mmbiz.qpic.cn/mmbiz_png/ok/0");
+
+ GzhPublishTool.ImageInlineResult r = tool.inlineContentImages(wx, html);
+
+ assertEquals(1, r.uploaded(), "the good image still uploads");
+ assertEquals(1, r.failed().size(), "the missing image is reported");
+ assertTrue(r.html().contains("http://mmbiz.qpic.cn/mmbiz_png/ok/0"));
+ }
+}