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: + *

    + *
  1. {@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.
  2. + *
  3. 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.
  4. + *
+ */ +@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) - ? "\"cover\"" - : ""; + // 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 = "\"cover\""; 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")); + } +}