mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
feat(content-studio): 生产硬化 —— 正文图上微信/密钥加密/token复用/内容日历去重/合规硬闸/封面兜底
This commit is contained in:
parent
81f4b8f827
commit
f184b94bcd
@ -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;
|
||||||
|
}
|
||||||
@ -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> {
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ import vip.mate.tool.search.SearchProvider;
|
|||||||
import vip.mate.tool.search.SearchProviderRegistry;
|
import vip.mate.tool.search.SearchProviderRegistry;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SystemSettingService {
|
public class SystemSettingService {
|
||||||
@ -86,8 +87,19 @@ public class SystemSettingService {
|
|||||||
private static final String MINIMAX_API_KEY_KEY = "minimaxApiKey";
|
private static final String MINIMAX_API_KEY_KEY = "minimaxApiKey";
|
||||||
private static final String MINIMAX_REGION_KEY = "minimaxRegion";
|
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 SystemSettingMapper systemSettingMapper;
|
||||||
private final SearchProviderRegistry searchProviderRegistry;
|
private final SearchProviderRegistry searchProviderRegistry;
|
||||||
|
private final SettingCrypto settingCrypto;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@code PluginManager} is injected lazily because the bean graph is
|
* {@code PluginManager} is injected lazily because the bean graph is
|
||||||
@ -105,9 +117,11 @@ public class SystemSettingService {
|
|||||||
|
|
||||||
public SystemSettingService(SystemSettingMapper systemSettingMapper,
|
public SystemSettingService(SystemSettingMapper systemSettingMapper,
|
||||||
SearchProviderRegistry searchProviderRegistry,
|
SearchProviderRegistry searchProviderRegistry,
|
||||||
|
SettingCrypto settingCrypto,
|
||||||
@Lazy PluginManager pluginManager) {
|
@Lazy PluginManager pluginManager) {
|
||||||
this.systemSettingMapper = systemSettingMapper;
|
this.systemSettingMapper = systemSettingMapper;
|
||||||
this.searchProviderRegistry = searchProviderRegistry;
|
this.searchProviderRegistry = searchProviderRegistry;
|
||||||
|
this.settingCrypto = settingCrypto;
|
||||||
this.pluginManager = pluginManager;
|
this.pluginManager = pluginManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -515,7 +529,12 @@ public class SystemSettingService {
|
|||||||
SystemSettingEntity entity = systemSettingMapper.selectOne(new LambdaQueryWrapper<SystemSettingEntity>()
|
SystemSettingEntity entity = systemSettingMapper.selectOne(new LambdaQueryWrapper<SystemSettingEntity>()
|
||||||
.eq(SystemSettingEntity::getSettingKey, key)
|
.eq(SystemSettingEntity::getSettingKey, key)
|
||||||
.last("LIMIT 1"));
|
.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) {
|
private String maskApiKey(String apiKey) {
|
||||||
@ -529,6 +548,10 @@ public class SystemSettingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void saveValue(String key, String value, String description) {
|
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>()
|
SystemSettingEntity entity = systemSettingMapper.selectOne(new LambdaQueryWrapper<SystemSettingEntity>()
|
||||||
.eq(SystemSettingEntity::getSettingKey, key)
|
.eq(SystemSettingEntity::getSettingKey, key)
|
||||||
.last("LIMIT 1"));
|
.last("LIMIT 1"));
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,6 +18,12 @@ import org.springframework.stereotype.Component;
|
|||||||
import vip.mate.tool.browser.UrlSafetyChecker;
|
import vip.mate.tool.browser.UrlSafetyChecker;
|
||||||
import vip.mate.tool.document.GeneratedFileCache;
|
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.io.ByteArrayOutputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -107,10 +113,16 @@ public class GzhPackageTool {
|
|||||||
// an actual image is dropped (and flagged) rather than rendered. This also
|
// 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.
|
// self-heals a reference that points at the file's name instead of its id.
|
||||||
ResolvedCover cover = resolveCover(coverImageUrl, ctx);
|
ResolvedCover cover = resolveCover(coverImageUrl, ctx);
|
||||||
String coverTag = (cover != null)
|
// Fallback: 公众号 requires a cover to publish, so never ship without one —
|
||||||
? "<img src=\"" + escapeAttr(cover.url()) + "\" alt=\"cover\" "
|
// synthesize a neutral gradient placeholder when none resolves.
|
||||||
+ "style=\"width:100%;border-radius:8px;margin:0 0 20px;display:block;\" />"
|
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())
|
String meta = (author != null && !author.isBlank())
|
||||||
? "<p style=\"color:" + FAINT + ";font-size:14px;margin:0 0 20px;\">" + escapeText(author.trim()) + "</p>"
|
? "<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();
|
StringBuilder out = new StringBuilder();
|
||||||
out.append("✅ 公众号图文已打包完成。\n\n");
|
out.append("✅ 公众号图文已打包完成。\n\n");
|
||||||
if (coverImageUrl != null && !coverImageUrl.isBlank() && cover == null) {
|
if (placeholderCover) {
|
||||||
// Requested a cover but it didn't resolve to an image — say so instead
|
// Never a broken image — but tell the user we substituted a placeholder.
|
||||||
// of silently shipping a broken image tag.
|
boolean hadRef = coverImageUrl != null && !coverImageUrl.isBlank();
|
||||||
out.append("⚠️ 提供的封面引用无法解析为图片,已跳过封面(未嵌坏图):")
|
out.append("⚠️ ")
|
||||||
.append(coverImageUrl.trim())
|
.append(hadRef ? "提供的封面无法解析为图片" : "未提供封面")
|
||||||
.append("\n 请改用 image_generate 返回的完整 URL(/api/v1/files/generated/<id>)再打包一次。\n\n");
|
.append(",已生成占位封面(纯色渐变)。建议补一张正式头图(2.35:1),")
|
||||||
|
.append("用 image_generate(aspectRatio=landscape) 出图后把完整 URL 传给 coverImageUrl 再打包。\n\n");
|
||||||
}
|
}
|
||||||
out.append("🔍 在线预览(浏览器打开即渲染):").append(previewUrl).append('\n');
|
out.append("🔍 在线预览(浏览器打开即渲染):").append(previewUrl).append('\n');
|
||||||
if (zipUrl != null) {
|
if (zipUrl != null) {
|
||||||
@ -293,6 +306,32 @@ public class GzhPackageTool {
|
|||||||
return null;
|
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) {
|
private static boolean isImage(GeneratedFileCache.Entry e) {
|
||||||
return e.mimeType() != null && e.mimeType().startsWith("image/");
|
return e.mimeType() != null && e.mimeType().startsWith("image/");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,20 +6,27 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import me.chanjar.weixin.common.api.WxConsts;
|
import me.chanjar.weixin.common.api.WxConsts;
|
||||||
import me.chanjar.weixin.common.error.WxErrorException;
|
import me.chanjar.weixin.common.error.WxErrorException;
|
||||||
import me.chanjar.weixin.mp.api.WxMpService;
|
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.WxMpAddDraft;
|
||||||
import me.chanjar.weixin.mp.bean.draft.WxMpDraftArticles;
|
import me.chanjar.weixin.mp.bean.draft.WxMpDraftArticles;
|
||||||
import me.chanjar.weixin.mp.bean.material.WxMpMaterial;
|
import me.chanjar.weixin.mp.bean.material.WxMpMaterial;
|
||||||
import me.chanjar.weixin.mp.bean.material.WxMpMaterialUploadResult;
|
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.Tool;
|
||||||
import org.springframework.ai.tool.annotation.ToolParam;
|
import org.springframework.ai.tool.annotation.ToolParam;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.system.service.SystemSettingService;
|
import vip.mate.system.service.SystemSettingService;
|
||||||
|
import vip.mate.tool.browser.UrlSafetyChecker;
|
||||||
|
import vip.mate.tool.document.GeneratedFileCache;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
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.
|
* 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 static final String SETTING_APP_SECRET = "weixinoa.app_secret";
|
||||||
|
|
||||||
private final SystemSettingService systemSettingService;
|
private final SystemSettingService systemSettingService;
|
||||||
|
private final WxMpServiceProvider wxMpServiceProvider;
|
||||||
|
private final GeneratedFileCache generatedFileCache;
|
||||||
|
|
||||||
@Tool(name = "gzh_publish", description = """
|
@Tool(name = "gzh_publish", description = """
|
||||||
Publish a generated image-text article to a WeChat Official Account (微信公众号).
|
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.";
|
+ "' 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();
|
String act = (action == null || action.isBlank()) ? "draft" : action.trim().toLowerCase();
|
||||||
|
|
||||||
return switch (act) {
|
return switch (act) {
|
||||||
@ -109,6 +118,16 @@ public class GzhPublishTool {
|
|||||||
return "Error: coverImageUrl is required — WeChat needs a cover/thumb for the article.";
|
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.
|
// 1. Download the cover and upload it as a permanent image material -> thumb media_id.
|
||||||
String thumbMediaId;
|
String thumbMediaId;
|
||||||
File tmpCover = null;
|
File tmpCover = null;
|
||||||
@ -118,15 +137,15 @@ public class GzhPublishTool {
|
|||||||
WxMpMaterial material = new WxMpMaterial();
|
WxMpMaterial material = new WxMpMaterial();
|
||||||
material.setName(tmpCover.getName());
|
material.setName(tmpCover.getName());
|
||||||
material.setFile(tmpCover);
|
material.setFile(tmpCover);
|
||||||
WxMpMaterialUploadResult uploaded = wxMpService.getMaterialService()
|
WxMpMaterialUploadResult uploaded = withRetry(() -> wxMpService.getMaterialService()
|
||||||
.materialFileUpload(WxConsts.MediaFileType.IMAGE, material);
|
.materialFileUpload(WxConsts.MediaFileType.IMAGE, material));
|
||||||
thumbMediaId = uploaded.getMediaId();
|
thumbMediaId = uploaded.getMediaId();
|
||||||
if (thumbMediaId == null || thumbMediaId.isBlank()) {
|
if (thumbMediaId == null || thumbMediaId.isBlank()) {
|
||||||
return "Error: cover upload returned no media_id.";
|
return "Error: cover upload returned no media_id.";
|
||||||
}
|
}
|
||||||
} catch (WxErrorException e) {
|
} catch (WxErrorException e) {
|
||||||
log.warn("[GzhPublish] cover upload failed: {}", e.getMessage());
|
log.warn("[GzhPublish] cover upload failed: {}", e.getMessage());
|
||||||
return "Error: cover upload failed — " + e.getMessage();
|
return "Error: 封面上传失败 — " + translateWxError(e);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[GzhPublish] cover download/upload failed: {}", e.getMessage());
|
log.warn("[GzhPublish] cover download/upload failed: {}", e.getMessage());
|
||||||
return "Error: 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 {
|
try {
|
||||||
WxMpDraftArticles article = new WxMpDraftArticles();
|
WxMpDraftArticles article = new WxMpDraftArticles();
|
||||||
article.setTitle(trimTo(title, 64));
|
article.setTitle(trimTo(title, 64));
|
||||||
article.setContent(content);
|
article.setContent(bodyHtml);
|
||||||
article.setThumbMediaId(thumbMediaId);
|
article.setThumbMediaId(thumbMediaId);
|
||||||
if (author != null && !author.isBlank()) {
|
if (author != null && !author.isBlank()) {
|
||||||
article.setAuthor(trimTo(author, 8));
|
article.setAuthor(trimTo(author, 8));
|
||||||
@ -150,17 +177,31 @@ public class GzhPublishTool {
|
|||||||
? trimTo(digest, 120)
|
? trimTo(digest, 120)
|
||||||
: deriveDigest(content));
|
: deriveDigest(content));
|
||||||
|
|
||||||
String draftMediaId = wxMpService.getDraftService()
|
String draftMediaId = withRetry(() -> wxMpService.getDraftService()
|
||||||
.addDraft(new WxMpAddDraft(List.of(article)));
|
.addDraft(new WxMpAddDraft(List.of(article))));
|
||||||
log.info("[GzhPublish] draft created, media_id={}, title='{}'", draftMediaId, title);
|
log.info("[GzhPublish] draft created, media_id={}, title='{}', imagesInlined={}, imagesFailed={}",
|
||||||
return "✅ 已存入公众号草稿箱。\n"
|
draftMediaId, title, inlined.uploaded(), inlined.failed().size());
|
||||||
+ "draft media_id: " + draftMediaId + "\n"
|
StringBuilder ok = new StringBuilder();
|
||||||
+ "请到公众号后台「草稿箱」核对排版后点击「发表」。\n"
|
ok.append("✅ 已存入公众号草稿箱。\n");
|
||||||
+ "如需直接群发(仅认证号),可用 gzh_publish action=publish draftMediaId=" + draftMediaId
|
ok.append("draft media_id: ").append(draftMediaId).append('\n');
|
||||||
+ " confirmPublish=true,并在发布前与用户再次确认内容。";
|
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) {
|
} catch (WxErrorException e) {
|
||||||
log.warn("[GzhPublish] addDraft failed: {}", e.getMessage());
|
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.";
|
+ "then call again with confirmPublish=true.";
|
||||||
}
|
}
|
||||||
try {
|
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);
|
log.info("[GzhPublish] free-publish submitted, publish_id={}, draft={}", publishId, draftMediaId);
|
||||||
return "✅ 已提交群发(free-publish)。publish_id: " + publishId
|
return "✅ 已提交群发(free-publish)。publish_id: " + publishId
|
||||||
+ "\n注意:发布结果由微信异步审核,请在公众号后台确认最终状态。";
|
+ "\n注意:发布结果由微信异步审核,请在公众号后台确认最终状态。";
|
||||||
} catch (WxErrorException e) {
|
} catch (WxErrorException e) {
|
||||||
log.warn("[GzhPublish] free-publish failed: {}", e.getMessage());
|
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) {
|
@FunctionalInterface
|
||||||
WxMpDefaultConfigImpl config = new WxMpDefaultConfigImpl();
|
private interface WxCall<T> {
|
||||||
config.setAppId(appId);
|
T get() throws WxErrorException;
|
||||||
config.setSecret(appSecret);
|
}
|
||||||
WxMpService service = new WxMpServiceImpl();
|
|
||||||
service.setWxMpConfigStorage(config);
|
/**
|
||||||
return service;
|
* 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. */
|
/** Strip tags and clamp to a length for the article digest. */
|
||||||
|
|||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||||
KEY (id)
|
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);
|
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);
|
||||||
|
|||||||
@ -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)
|
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)
|
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;
|
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;
|
||||||
|
|||||||
@ -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)
|
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)
|
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;
|
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;
|
||||||
|
|||||||
@ -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)
|
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)
|
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);
|
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);
|
||||||
|
|||||||
@ -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)
|
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)
|
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);
|
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);
|
||||||
|
|||||||
@ -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)
|
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||||
KEY (id)
|
KEY (id)
|
||||||
VALUES (1000000635, 'XhsPackageTool', '小红书打包', '把小红书笔记打包成在线预览(手机版滑动预览,以图为主、文字辅助)+ 素材下载 zip(编号卡片图 + 文案.txt)。强制至少 3 张竖版图(1 封面 + ≥2 内容图),不足则拒绝打包。小红书无发布 API,不自动上传。', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0);
|
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);
|
||||||
|
|||||||
@ -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);
|
||||||
@ -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;
|
||||||
@ -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);
|
||||||
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: gzh_article
|
name: gzh_article
|
||||||
description: '公众号图文创作 / 推文 / 官方号文章 (official account article) — 端到端:选题→搜集→成文→配图→去AI化→公众号内联样式排版→交付/草稿箱。honors user persona & style memory.'
|
description: '公众号图文创作 / 推文 / 官方号文章 (official account article) — 端到端:选题→搜集→成文→配图→去AI化→公众号内联样式排版→交付/草稿箱。honors user persona & style memory.'
|
||||||
version: 1.1.0
|
version: 1.2.0
|
||||||
tags:
|
tags:
|
||||||
- 公众号
|
- 公众号
|
||||||
- 图文
|
- 图文
|
||||||
@ -102,6 +102,14 @@ gzh_package(title="<标题>", markdown="<正文 Markdown,含小标题/列表/
|
|||||||
|
|
||||||
`references/` 里的 `gzh_layout*.html` 是**服务端配色/排版的参考**(`gzh_package` 已内置同款风格);只有当用户明确要**高度定制的特殊版式**、且篇幅不大时,才手写内联 HTML 并用 `render_html_image(html=...)` 出预览图,注意控制体量避免截断。
|
`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` 才能写入):
|
当用户对某个自创模板满意、想以后复用时,用 `skill_manage` 把它**存成一个自定义技能**(`builtin=false` 才能写入):
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: xhs_note
|
name: xhs_note
|
||||||
description: '小红书图文创作 / 笔记 / 种草文案 (xiaohongshu / red note) — 端到端:成文→配图(≥3 张竖版)→去AI化→在线预览打包交付。以图为主、文字辅助:标题四件套 + 碎句正文 + 话题标签,配 3:4 竖版卡片,最少 3 张图。honors user persona & style memory.'
|
description: '小红书图文创作 / 笔记 / 种草文案 (xiaohongshu / red note) — 端到端:成文→配图(≥3 张竖版)→去AI化→在线预览打包交付。以图为主、文字辅助:标题四件套 + 碎句正文 + 话题标签,配 3:4 竖版卡片,最少 3 张图。honors user persona & style memory.'
|
||||||
version: 1.1.0
|
version: 1.2.0
|
||||||
tags:
|
tags:
|
||||||
- 小红书
|
- 小红书
|
||||||
- 图文
|
- 图文
|
||||||
@ -116,6 +116,14 @@ xhs_package(title="<标题>", body="<正文,含 emoji 与换行>",
|
|||||||
|
|
||||||
打包前对照 `banned_words` 扫一遍正文和标题,命中即标注替换。
|
打包前对照 `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` 才能写):
|
用户满意某个自创卡片、想复用时,用 `skill_manage` 存成**自定义技能**(`builtin=false` 才能写):
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -46,7 +46,8 @@ class SystemSettingBoolApiTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
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) {
|
private SystemSettingEntity row(String value) {
|
||||||
|
|||||||
@ -68,7 +68,7 @@ class SystemSettingServiceCatalogTest {
|
|||||||
@DisplayName("marks builtin providers as builtin=true with no pluginName")
|
@DisplayName("marks builtin providers as builtin=true with no pluginName")
|
||||||
void builtinEntry() {
|
void builtinEntry() {
|
||||||
SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("serper", 300, true, false)));
|
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();
|
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ class SystemSettingServiceCatalogTest {
|
|||||||
SearchProviderRegistry registry = new SearchProviderRegistry(List.of());
|
SearchProviderRegistry registry = new SearchProviderRegistry(List.of());
|
||||||
registry.registerPluginProvider(stub("my-search", 500, true, true));
|
registry.registerPluginProvider(stub("my-search", 500, true, true));
|
||||||
when(pluginManager.getPluginNameForSearchProvider("my-search")).thenReturn("my-plugin");
|
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();
|
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
|
||||||
|
|
||||||
@ -105,7 +105,7 @@ class SystemSettingServiceCatalogTest {
|
|||||||
stub("duckduckgo", 100, false, true)));
|
stub("duckduckgo", 100, false, true)));
|
||||||
registry.registerPluginProvider(stub("my-search", 200, true, true));
|
registry.registerPluginProvider(stub("my-search", 200, true, true));
|
||||||
when(pluginManager.getPluginNameForSearchProvider("my-search")).thenReturn("my-plugin");
|
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();
|
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
|
||||||
|
|
||||||
@ -128,7 +128,7 @@ class SystemSettingServiceCatalogTest {
|
|||||||
@DisplayName("surfaces the resolved provider id and source alongside the catalog")
|
@DisplayName("surfaces the resolved provider id and source alongside the catalog")
|
||||||
void resolvedSurfaced() {
|
void resolvedSurfaced() {
|
||||||
SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("duckduckgo", 100, false, true)));
|
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();
|
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
|
||||||
|
|
||||||
@ -140,7 +140,7 @@ class SystemSettingServiceCatalogTest {
|
|||||||
@DisplayName("resolvedId/resolvedSource are null when no provider is available at all")
|
@DisplayName("resolvedId/resolvedSource are null when no provider is available at all")
|
||||||
void resolvedNullWhenNothingAvailable() {
|
void resolvedNullWhenNothingAvailable() {
|
||||||
SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("serper", 300, true, false)));
|
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();
|
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
|
||||||
|
|
||||||
|
|||||||
@ -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("未命中"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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:"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -53,8 +53,8 @@ class GzhPackageCoverHealingTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("unresolvable cover → dropped with a warning, never a broken <img>")
|
@DisplayName("unresolvable cover → placeholder cover + warning, never a broken <img>")
|
||||||
void unresolvableCoverDroppedAndWarned() {
|
void unresolvableCoverUsesPlaceholder() {
|
||||||
String out = tool.gzh_package(
|
String out = tool.gzh_package(
|
||||||
"标题",
|
"标题",
|
||||||
BODY,
|
BODY,
|
||||||
@ -63,7 +63,9 @@ class GzhPackageCoverHealingTest {
|
|||||||
null);
|
null);
|
||||||
|
|
||||||
assertTrue(out.contains("⚠️"), "an unresolved cover must be flagged; got:\n" + out);
|
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
|
@Test
|
||||||
@ -83,8 +85,8 @@ class GzhPackageCoverHealingTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("a non-image generated file referenced as cover is not embedded")
|
@DisplayName("a non-image generated file referenced as cover → placeholder, not the non-image")
|
||||||
void nonImageReferenceNotEmbedded() {
|
void nonImageReferenceFallsBackToPlaceholder() {
|
||||||
String id = cache.put("%PDF".getBytes(), "handout.pdf", "application/pdf");
|
String id = cache.put("%PDF".getBytes(), "handout.pdf", "application/pdf");
|
||||||
String out = tool.gzh_package(
|
String out = tool.gzh_package(
|
||||||
"标题",
|
"标题",
|
||||||
@ -94,6 +96,7 @@ class GzhPackageCoverHealingTest {
|
|||||||
null);
|
null);
|
||||||
|
|
||||||
assertTrue(out.contains("⚠️"), "a non-image cover must be flagged");
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user