feat(content-studio): 生产硬化 —— 正文图上微信/密钥加密/token复用/内容日历去重/合规硬闸/封面兜底

This commit is contained in:
mateaix 2026-07-11 22:32:14 +08:00
parent 81f4b8f827
commit f184b94bcd
28 changed files with 1348 additions and 51 deletions

View File

@ -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.
*
* <p>{@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;
}

View File

@ -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<ContentItemEntity> {
}

View File

@ -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:<base64(iv||ciphertext||tag)>}.
*
* <p>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.
*
* <p>Key source, in order:
* <ol>
* <li>{@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.</li>
* <li>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.</li>
* </ol>
*/
@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;
}
}

View File

@ -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<String> 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<SystemSettingEntity>()
.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<SystemSettingEntity>()
.eq(SystemSettingEntity::getSettingKey, key)
.last("LIMIT 1"));

View File

@ -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);
}
}

View File

@ -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.
*
* <p>Four categories, roughly ordered by account risk:
* <ul>
* <li>{@code 广告法极限词} 绝对化用语/第一/唯一/国家级/100%</li>
* <li>{@code 微信诱导} 诱导分享/关注集赞/助力/分享解锁/关注才能看 WeChat's most account-fatal rule</li>
* <li>{@code 承诺收益/效果} 保本/稳赚/包过/根治</li>
* <li>{@code 医疗功效} 治愈/抗癌/包瘦/排毒</li>
* </ul>
* 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<String, Pattern> RULES = new LinkedHashMap<>();
/** Categories that a publish path should hard-block on. */
private static final List<String> 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<String> terms, boolean highRisk) {}
/** Full scan result. */
record Result(List<CategoryHit> 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<CategoryHit> hits = new ArrayList<>();
if (text == null || text.isBlank()) {
return new Result(hits);
}
for (Map.Entry<String, Pattern> rule : RULES.entrySet()) {
List<String> 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();
}
}

View File

@ -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.
*
* <p>Actions:
* <ul>
* <li>{@code check_recent} has this topic been produced on this platform in
* the last N days? Call BEFORE committing to a topic.</li>
* <li>{@code record} log a produced piece (draft/packaged) with its title and
* preview link.</li>
* <li>{@code mark_published} flip an item to published with its platform ref.</li>
* </ul>
*/
@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<ContentItemEntity> recent = contentItemMapper.selectList(
new LambdaQueryWrapper<ContentItemEntity>()
.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();
}
}

View File

@ -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)
? "<img src=\"" + escapeAttr(cover.url()) + "\" alt=\"cover\" "
+ "style=\"width:100%;border-radius:8px;margin:0 0 20px;display:block;\" />"
: "";
// 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 = "<img src=\"" + escapeAttr(cover.url()) + "\" alt=\"cover\" "
+ "style=\"width:100%;border-radius:8px;margin:0 0 20px;display:block;\" />";
String meta = (author != null && !author.isBlank())
? "<p style=\"color:" + FAINT + ";font-size:14px;margin:0 0 20px;\">" + escapeText(author.trim()) + "</p>"
: "";
@ -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/<id>)再打包一次。\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/");
}

View File

@ -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 <img src>
// (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> {
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> T withRetry(WxCall<T> 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<String> 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<String> 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<GeneratedFileCache.Entry> 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. */

View File

@ -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.
*
* <p>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<String, Holder> 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());
}
}
}
}

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -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` 才能写入):

View File

@ -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` 才能写):

View File

@ -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");
}
}

View File

@ -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) {

View File

@ -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();

View File

@ -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("未命中"));
}
}

View File

@ -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:"));
}
}

View File

@ -53,8 +53,8 @@ class GzhPackageCoverHealingTest {
}
@Test
@DisplayName("unresolvable cover → dropped with a warning, never a broken <img>")
void unresolvableCoverDroppedAndWarned() {
@DisplayName("unresolvable cover → placeholder cover + warning, never a broken <img>")
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("<img "), "no broken cover image may be embedded");
assertTrue(out.contains("占位封面"), "a placeholder cover must be substituted");
assertTrue(out.contains("<img "), "the placeholder cover is embedded (never broken/absent)");
assertFalse(out.contains("generated/deadbeef"), "the broken ref must not be embedded");
}
@Test
@ -83,8 +85,8 @@ class GzhPackageCoverHealingTest {
}
@Test
@DisplayName("a non-image generated file referenced as cover is not embedded")
void nonImageReferenceNotEmbedded() {
@DisplayName("a non-image generated file referenced as cover → placeholder, not the non-image")
void nonImageReferenceFallsBackToPlaceholder() {
String id = cache.put("%PDF".getBytes(), "handout.pdf", "application/pdf");
String out = tool.gzh_package(
"标题",
@ -94,6 +96,7 @@ class GzhPackageCoverHealingTest {
null);
assertTrue(out.contains("⚠️"), "a non-image cover must be flagged");
assertFalse(out.contains("<img "), "a non-image must not be embedded as a cover");
assertTrue(out.contains("占位封面"), "a placeholder cover must be substituted");
assertFalse(out.contains("generated/" + id), "the non-image ref must not be embedded as the cover");
}
}

View File

@ -0,0 +1,90 @@
package vip.mate.tool.builtin;
import me.chanjar.weixin.mp.api.WxMpMaterialService;
import me.chanjar.weixin.mp.api.WxMpService;
import me.chanjar.weixin.mp.bean.material.WxMediaImgUploadResult;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import vip.mate.tool.document.GeneratedFileCache;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Pin the fix for the biggest real-publishing gap: an article body whose images
* point at our generated-file URLs (or any non-WeChat host) renders broken once
* published, because WeChat only shows images it hosts. {@code gzh_publish} must
* upload each body image and rewrite its src to the {@code mp.weixin.qq.com} URL,
* leave already-WeChat images alone, and never let one failed image block the draft.
*/
class GzhPublishImageInlineTest {
private GeneratedFileCache cache;
private GzhPublishTool tool;
@BeforeEach
void setUp(@TempDir Path tempDir) {
cache = new GeneratedFileCache(tempDir);
// Only the generated-file cache is exercised by inlineContentImages.
tool = new GzhPublishTool(null, null, cache);
}
private WxMpService wxReturning(String mpUrl) throws Exception {
WxMediaImgUploadResult result = mock(WxMediaImgUploadResult.class);
when(result.getUrl()).thenReturn(mpUrl);
WxMpMaterialService material = mock(WxMpMaterialService.class);
when(material.mediaImgUpload(any())).thenReturn(result);
WxMpService wx = mock(WxMpService.class);
when(wx.getMaterialService()).thenReturn(material);
return wx;
}
@Test
@DisplayName("a generated-file body image is uploaded and its src rewritten to the WeChat URL")
void rewritesGeneratedImage() throws Exception {
String id = cache.put("PNGDATA".getBytes(), "pic.png", "image/png");
String html = "<p>看图</p><img src=\"/api/v1/files/generated/" + id + "\"/>";
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 = "<img src=\"https://mp.weixin.qq.com/existing.png\"/>";
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 = "<img src=\"/api/v1/files/generated/deadbeef-0000-0000-0000-000000000000\"/>"
+ "<img src=\"/api/v1/files/generated/" + good + "\"/>";
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"));
}
}