mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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 java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class SystemSettingService {
|
||||
@ -86,8 +87,19 @@ public class SystemSettingService {
|
||||
private static final String MINIMAX_API_KEY_KEY = "minimaxApiKey";
|
||||
private static final String MINIMAX_REGION_KEY = "minimaxRegion";
|
||||
|
||||
/**
|
||||
* Keys whose values are secrets and must be encrypted at rest. Reads decrypt
|
||||
* transparently and writes encrypt; legacy plaintext is upgraded on next save
|
||||
* (see {@link SettingCrypto}). Add every credential-bearing key here.
|
||||
*/
|
||||
private static final Set<String> SENSITIVE_KEYS = Set.of(
|
||||
SERPER_API_KEY_KEY, TAVILY_API_KEY_KEY, WEIXINOA_APP_SECRET_KEY,
|
||||
ZHIPU_API_KEY_KEY, FAL_API_KEY_KEY, KLING_ACCESS_KEY_KEY, KLING_SECRET_KEY_KEY,
|
||||
RUNWAY_API_KEY_KEY, MINIMAX_API_KEY_KEY);
|
||||
|
||||
private final SystemSettingMapper systemSettingMapper;
|
||||
private final SearchProviderRegistry searchProviderRegistry;
|
||||
private final SettingCrypto settingCrypto;
|
||||
|
||||
/**
|
||||
* {@code PluginManager} is injected lazily because the bean graph is
|
||||
@ -105,9 +117,11 @@ public class SystemSettingService {
|
||||
|
||||
public SystemSettingService(SystemSettingMapper systemSettingMapper,
|
||||
SearchProviderRegistry searchProviderRegistry,
|
||||
SettingCrypto settingCrypto,
|
||||
@Lazy PluginManager pluginManager) {
|
||||
this.systemSettingMapper = systemSettingMapper;
|
||||
this.searchProviderRegistry = searchProviderRegistry;
|
||||
this.settingCrypto = settingCrypto;
|
||||
this.pluginManager = pluginManager;
|
||||
}
|
||||
|
||||
@ -515,7 +529,12 @@ public class SystemSettingService {
|
||||
SystemSettingEntity entity = systemSettingMapper.selectOne(new LambdaQueryWrapper<SystemSettingEntity>()
|
||||
.eq(SystemSettingEntity::getSettingKey, key)
|
||||
.last("LIMIT 1"));
|
||||
return entity != null && entity.getSettingValue() != null ? entity.getSettingValue() : defaultValue;
|
||||
if (entity == null || entity.getSettingValue() == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
String stored = entity.getSettingValue();
|
||||
// Sensitive keys are stored encrypted; decrypt() passes legacy plaintext through.
|
||||
return SENSITIVE_KEYS.contains(key) ? settingCrypto.decrypt(stored) : stored;
|
||||
}
|
||||
|
||||
private String maskApiKey(String apiKey) {
|
||||
@ -529,6 +548,10 @@ public class SystemSettingService {
|
||||
}
|
||||
|
||||
private void saveValue(String key, String value, String description) {
|
||||
// Encrypt secrets at rest; non-blank only (blank passes through to clear).
|
||||
if (SENSITIVE_KEYS.contains(key) && value != null && !value.isEmpty()) {
|
||||
value = settingCrypto.encrypt(value);
|
||||
}
|
||||
SystemSettingEntity entity = systemSettingMapper.selectOne(new LambdaQueryWrapper<SystemSettingEntity>()
|
||||
.eq(SystemSettingEntity::getSettingKey, key)
|
||||
.last("LIMIT 1"));
|
||||
|
||||
@ -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.document.GeneratedFileCache;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.Color;
|
||||
import java.awt.GradientPaint;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
@ -107,10 +113,16 @@ public class GzhPackageTool {
|
||||
// an actual image is dropped (and flagged) rather than rendered. This also
|
||||
// self-heals a reference that points at the file's name instead of its id.
|
||||
ResolvedCover cover = resolveCover(coverImageUrl, ctx);
|
||||
String coverTag = (cover != null)
|
||||
? "<img src=\"" + escapeAttr(cover.url()) + "\" alt=\"cover\" "
|
||||
+ "style=\"width:100%;border-radius:8px;margin:0 0 20px;display:block;\" />"
|
||||
: "";
|
||||
// Fallback: 公众号 requires a cover to publish, so never ship without one —
|
||||
// synthesize a neutral gradient placeholder when none resolves.
|
||||
boolean placeholderCover = false;
|
||||
if (cover == null) {
|
||||
byte[] ph = placeholderCover();
|
||||
cover = new ResolvedCover(ph, store(ph, "gzh-cover-placeholder.png", "image/png", ctx));
|
||||
placeholderCover = true;
|
||||
}
|
||||
String coverTag = "<img src=\"" + escapeAttr(cover.url()) + "\" alt=\"cover\" "
|
||||
+ "style=\"width:100%;border-radius:8px;margin:0 0 20px;display:block;\" />";
|
||||
String meta = (author != null && !author.isBlank())
|
||||
? "<p style=\"color:" + FAINT + ";font-size:14px;margin:0 0 20px;\">" + escapeText(author.trim()) + "</p>"
|
||||
: "";
|
||||
@ -156,12 +168,13 @@ public class GzhPackageTool {
|
||||
|
||||
StringBuilder out = new StringBuilder();
|
||||
out.append("✅ 公众号图文已打包完成。\n\n");
|
||||
if (coverImageUrl != null && !coverImageUrl.isBlank() && cover == null) {
|
||||
// Requested a cover but it didn't resolve to an image — say so instead
|
||||
// of silently shipping a broken image tag.
|
||||
out.append("⚠️ 提供的封面引用无法解析为图片,已跳过封面(未嵌坏图):")
|
||||
.append(coverImageUrl.trim())
|
||||
.append("\n 请改用 image_generate 返回的完整 URL(/api/v1/files/generated/<id>)再打包一次。\n\n");
|
||||
if (placeholderCover) {
|
||||
// Never a broken image — but tell the user we substituted a placeholder.
|
||||
boolean hadRef = coverImageUrl != null && !coverImageUrl.isBlank();
|
||||
out.append("⚠️ ")
|
||||
.append(hadRef ? "提供的封面无法解析为图片" : "未提供封面")
|
||||
.append(",已生成占位封面(纯色渐变)。建议补一张正式头图(2.35:1),")
|
||||
.append("用 image_generate(aspectRatio=landscape) 出图后把完整 URL 传给 coverImageUrl 再打包。\n\n");
|
||||
}
|
||||
out.append("🔍 在线预览(浏览器打开即渲染):").append(previewUrl).append('\n');
|
||||
if (zipUrl != null) {
|
||||
@ -293,6 +306,32 @@ public class GzhPackageTool {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A neutral 2.35:1 gradient placeholder cover. Deliberately text-free — server
|
||||
* JVMs often lack CJK fonts, so drawing the title risks tofu boxes; a clean
|
||||
* gradient is a always-valid cover the user can replace with a real one.
|
||||
*/
|
||||
private static byte[] placeholderCover() {
|
||||
int w = 900, h = 383;
|
||||
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g.setPaint(new GradientPaint(0, 0, new Color(0x2f6fed), w, h, new Color(0x1a3a8f)));
|
||||
g.fillRect(0, 0, w, h);
|
||||
// A couple of soft translucent circles for a bit of depth.
|
||||
g.setColor(new Color(255, 255, 255, 26));
|
||||
g.fillOval(w - 220, -120, 340, 340);
|
||||
g.fillOval(-80, h - 160, 260, 260);
|
||||
g.dispose();
|
||||
try {
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "png", bos);
|
||||
return bos.toByteArray();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to render placeholder cover", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isImage(GeneratedFileCache.Entry e) {
|
||||
return e.mimeType() != null && e.mimeType().startsWith("image/");
|
||||
}
|
||||
|
||||
@ -6,20 +6,27 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import me.chanjar.weixin.common.api.WxConsts;
|
||||
import me.chanjar.weixin.common.error.WxErrorException;
|
||||
import me.chanjar.weixin.mp.api.WxMpService;
|
||||
import me.chanjar.weixin.mp.api.impl.WxMpServiceImpl;
|
||||
import me.chanjar.weixin.mp.bean.draft.WxMpAddDraft;
|
||||
import me.chanjar.weixin.mp.bean.draft.WxMpDraftArticles;
|
||||
import me.chanjar.weixin.mp.bean.material.WxMpMaterial;
|
||||
import me.chanjar.weixin.mp.bean.material.WxMpMaterialUploadResult;
|
||||
import me.chanjar.weixin.mp.config.impl.WxMpDefaultConfigImpl;
|
||||
import me.chanjar.weixin.mp.bean.material.WxMediaImgUploadResult;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.system.service.SystemSettingService;
|
||||
import vip.mate.tool.browser.UrlSafetyChecker;
|
||||
import vip.mate.tool.document.GeneratedFileCache;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
/**
|
||||
* Built-in tool: publish a generated 图文 article to a WeChat Official Account.
|
||||
@ -44,6 +51,8 @@ public class GzhPublishTool {
|
||||
private static final String SETTING_APP_SECRET = "weixinoa.app_secret";
|
||||
|
||||
private final SystemSettingService systemSettingService;
|
||||
private final WxMpServiceProvider wxMpServiceProvider;
|
||||
private final GeneratedFileCache generatedFileCache;
|
||||
|
||||
@Tool(name = "gzh_publish", description = """
|
||||
Publish a generated image-text article to a WeChat Official Account (微信公众号).
|
||||
@ -87,7 +96,7 @@ public class GzhPublishTool {
|
||||
+ "' and '" + SETTING_APP_SECRET + "' in system settings first.";
|
||||
}
|
||||
|
||||
WxMpService wxMpService = buildService(appId, appSecret);
|
||||
WxMpService wxMpService = wxMpServiceProvider.getService(appId, appSecret);
|
||||
String act = (action == null || action.isBlank()) ? "draft" : action.trim().toLowerCase();
|
||||
|
||||
return switch (act) {
|
||||
@ -109,6 +118,16 @@ public class GzhPublishTool {
|
||||
return "Error: coverImageUrl is required — WeChat needs a cover/thumb for the article.";
|
||||
}
|
||||
|
||||
// Hard compliance gate (fail fast, before any upload): refuse to draft
|
||||
// account-fatal copy — 广告法 极限词 / 微信诱导 / 承诺收益. Lower-risk hits
|
||||
// (医疗功效) are surfaced as a warning on success instead.
|
||||
ComplianceScanner.Result scan = ComplianceScanner.scan(
|
||||
title + "\n" + content.replaceAll("<[^>]+>", " "));
|
||||
if (scan.hasHighRisk()) {
|
||||
return "⛔ 合规拦截:命中高危违规词,已阻止进入草稿箱,请替换后再发。\n"
|
||||
+ ComplianceScanner.report(scan);
|
||||
}
|
||||
|
||||
// 1. Download the cover and upload it as a permanent image material -> thumb media_id.
|
||||
String thumbMediaId;
|
||||
File tmpCover = null;
|
||||
@ -118,15 +137,15 @@ public class GzhPublishTool {
|
||||
WxMpMaterial material = new WxMpMaterial();
|
||||
material.setName(tmpCover.getName());
|
||||
material.setFile(tmpCover);
|
||||
WxMpMaterialUploadResult uploaded = wxMpService.getMaterialService()
|
||||
.materialFileUpload(WxConsts.MediaFileType.IMAGE, material);
|
||||
WxMpMaterialUploadResult uploaded = withRetry(() -> wxMpService.getMaterialService()
|
||||
.materialFileUpload(WxConsts.MediaFileType.IMAGE, material));
|
||||
thumbMediaId = uploaded.getMediaId();
|
||||
if (thumbMediaId == null || thumbMediaId.isBlank()) {
|
||||
return "Error: cover upload returned no media_id.";
|
||||
}
|
||||
} catch (WxErrorException e) {
|
||||
log.warn("[GzhPublish] cover upload failed: {}", e.getMessage());
|
||||
return "Error: cover upload failed — " + e.getMessage();
|
||||
return "Error: 封面上传失败 — " + translateWxError(e);
|
||||
} catch (Exception e) {
|
||||
log.warn("[GzhPublish] cover download/upload failed: {}", e.getMessage());
|
||||
return "Error: cover download/upload failed — " + e.getMessage();
|
||||
@ -137,11 +156,19 @@ public class GzhPublishTool {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Build the draft article and submit it.
|
||||
// 2. Inline body images into WeChat: article HTML with external <img src>
|
||||
// (our generated-file URLs, localhost, any non-mp host) renders broken in
|
||||
// the published article — WeChat only displays images it hosts. Upload each
|
||||
// and rewrite src to the returned mp.weixin.qq.com URL. Failures don't block
|
||||
// the draft; they're reported so the user can fix those images by hand.
|
||||
ImageInlineResult inlined = inlineContentImages(wxMpService, content);
|
||||
String bodyHtml = inlined.html();
|
||||
|
||||
// 3. Build the draft article and submit it.
|
||||
try {
|
||||
WxMpDraftArticles article = new WxMpDraftArticles();
|
||||
article.setTitle(trimTo(title, 64));
|
||||
article.setContent(content);
|
||||
article.setContent(bodyHtml);
|
||||
article.setThumbMediaId(thumbMediaId);
|
||||
if (author != null && !author.isBlank()) {
|
||||
article.setAuthor(trimTo(author, 8));
|
||||
@ -150,17 +177,31 @@ public class GzhPublishTool {
|
||||
? trimTo(digest, 120)
|
||||
: deriveDigest(content));
|
||||
|
||||
String draftMediaId = wxMpService.getDraftService()
|
||||
.addDraft(new WxMpAddDraft(List.of(article)));
|
||||
log.info("[GzhPublish] draft created, media_id={}, title='{}'", draftMediaId, title);
|
||||
return "✅ 已存入公众号草稿箱。\n"
|
||||
+ "draft media_id: " + draftMediaId + "\n"
|
||||
+ "请到公众号后台「草稿箱」核对排版后点击「发表」。\n"
|
||||
+ "如需直接群发(仅认证号),可用 gzh_publish action=publish draftMediaId=" + draftMediaId
|
||||
+ " confirmPublish=true,并在发布前与用户再次确认内容。";
|
||||
String draftMediaId = withRetry(() -> wxMpService.getDraftService()
|
||||
.addDraft(new WxMpAddDraft(List.of(article))));
|
||||
log.info("[GzhPublish] draft created, media_id={}, title='{}', imagesInlined={}, imagesFailed={}",
|
||||
draftMediaId, title, inlined.uploaded(), inlined.failed().size());
|
||||
StringBuilder ok = new StringBuilder();
|
||||
ok.append("✅ 已存入公众号草稿箱。\n");
|
||||
ok.append("draft media_id: ").append(draftMediaId).append('\n');
|
||||
if (inlined.uploaded() > 0) {
|
||||
ok.append("正文图已上传微信并改写链接:").append(inlined.uploaded()).append(" 张。\n");
|
||||
}
|
||||
if (!inlined.failed().isEmpty()) {
|
||||
ok.append("⚠️ 有 ").append(inlined.failed().size())
|
||||
.append(" 张正文图未能上传(发布后会裂图,请在后台手动替换):")
|
||||
.append(String.join(";", inlined.failed())).append('\n');
|
||||
}
|
||||
if (!scan.clean()) {
|
||||
ok.append("⚠️ 合规提示(非高危,建议核对):").append(ComplianceScanner.report(scan)).append('\n');
|
||||
}
|
||||
ok.append("请到公众号后台「草稿箱」核对排版后点击「发表」。\n");
|
||||
ok.append("如需直接群发(仅认证号),可用 gzh_publish action=publish draftMediaId=").append(draftMediaId)
|
||||
.append(" confirmPublish=true,并在发布前与用户再次确认内容。");
|
||||
return ok.toString();
|
||||
} catch (WxErrorException e) {
|
||||
log.warn("[GzhPublish] addDraft failed: {}", e.getMessage());
|
||||
return "Error: creating the draft failed — " + e.getMessage();
|
||||
return "Error: 创建草稿失败 — " + translateWxError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@ -173,23 +214,146 @@ public class GzhPublishTool {
|
||||
+ "then call again with confirmPublish=true.";
|
||||
}
|
||||
try {
|
||||
String publishId = wxMpService.getFreePublishService().submit(draftMediaId);
|
||||
String publishId = withRetry(() -> wxMpService.getFreePublishService().submit(draftMediaId));
|
||||
log.info("[GzhPublish] free-publish submitted, publish_id={}, draft={}", publishId, draftMediaId);
|
||||
return "✅ 已提交群发(free-publish)。publish_id: " + publishId
|
||||
+ "\n注意:发布结果由微信异步审核,请在公众号后台确认最终状态。";
|
||||
} catch (WxErrorException e) {
|
||||
log.warn("[GzhPublish] free-publish failed: {}", e.getMessage());
|
||||
return "Error: free-publish failed (verified accounts only) — " + e.getMessage();
|
||||
return "Error: 群发失败(仅认证号可用)— " + translateWxError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private WxMpService buildService(String appId, String appSecret) {
|
||||
WxMpDefaultConfigImpl config = new WxMpDefaultConfigImpl();
|
||||
config.setAppId(appId);
|
||||
config.setSecret(appSecret);
|
||||
WxMpService service = new WxMpServiceImpl();
|
||||
service.setWxMpConfigStorage(config);
|
||||
return service;
|
||||
@FunctionalInterface
|
||||
private interface WxCall<T> {
|
||||
T get() throws WxErrorException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a WeChat call on transient error codes (system busy / rate limit) with
|
||||
* a short backoff, up to 2 extra attempts. Non-transient errors (bad token,
|
||||
* IP whitelist, unauthorized) throw immediately — retrying them is pointless.
|
||||
*/
|
||||
private static <T> T withRetry(WxCall<T> call) throws WxErrorException {
|
||||
int attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return call.get();
|
||||
} catch (WxErrorException e) {
|
||||
int code = e.getError() != null ? e.getError().getErrorCode() : 0;
|
||||
boolean transient_ = (code == -1 || code == 45009);
|
||||
if (transient_ && attempt < 2) {
|
||||
attempt++;
|
||||
try {
|
||||
Thread.sleep(500L * attempt);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw e;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Translate a WeChat error into an actionable Chinese hint (falls back to the raw message). */
|
||||
static String translateWxError(WxErrorException e) {
|
||||
int code = e.getError() != null ? e.getError().getErrorCode() : 0;
|
||||
String hint = switch (code) {
|
||||
case 40164 -> "服务器公网 IP 不在公众号后台白名单。到「设置与开发 → 安全中心 / IP 白名单」把本机公网 IP 加进去后重试。";
|
||||
case 48001 -> "接口未授权:该能力通常仅认证服务号可用。";
|
||||
case 40001, 42001 -> "access_token 无效或已过期:请核对 AppID/AppSecret,或稍后重试。";
|
||||
case 45009 -> "接口调用频率超限:请稍后再试。";
|
||||
case -1 -> "微信系统繁忙:请稍后再试。";
|
||||
default -> "";
|
||||
};
|
||||
return hint.isEmpty()
|
||||
? (e.getMessage() == null ? "微信接口错误" : e.getMessage())
|
||||
: hint + "(errcode=" + code + ")";
|
||||
}
|
||||
|
||||
/** Result of rewriting article body images to WeChat-hosted URLs. */
|
||||
record ImageInlineResult(String html, int uploaded, List<String> failed) {}
|
||||
|
||||
/**
|
||||
* Upload every non-WeChat body image to the Official Account and rewrite its
|
||||
* {@code src} to the returned {@code mp.weixin.qq.com} URL, so images actually
|
||||
* render in the published article. Images already on {@code mp.weixin.qq.com}
|
||||
* (or {@code data:} URIs) are left as-is. A single image failing to upload is
|
||||
* recorded and skipped — it never blocks the whole draft.
|
||||
*/
|
||||
ImageInlineResult inlineContentImages(WxMpService wxMpService, String html) {
|
||||
if (html == null || html.isBlank()) {
|
||||
return new ImageInlineResult(html, 0, List.of());
|
||||
}
|
||||
Document doc = Jsoup.parseBodyFragment(html);
|
||||
doc.outputSettings().prettyPrint(false);
|
||||
List<String> failed = new ArrayList<>();
|
||||
int uploaded = 0;
|
||||
for (Element img : doc.select("img[src]")) {
|
||||
String src = img.attr("src").trim();
|
||||
if (src.isEmpty() || src.contains("mp.weixin.qq.com") || src.startsWith("data:")) {
|
||||
continue;
|
||||
}
|
||||
File tmp = null;
|
||||
try {
|
||||
byte[] bytes = resolveImageBytes(src);
|
||||
if (bytes == null || bytes.length == 0) {
|
||||
failed.add(src);
|
||||
continue;
|
||||
}
|
||||
tmp = Files.createTempFile("gzh_img_", "." + extOf(src)).toFile();
|
||||
Files.write(tmp.toPath(), bytes);
|
||||
WxMediaImgUploadResult result = wxMpService.getMaterialService().mediaImgUpload(tmp);
|
||||
if (result != null && result.getUrl() != null && !result.getUrl().isBlank()) {
|
||||
img.attr("src", result.getUrl());
|
||||
uploaded++;
|
||||
} else {
|
||||
failed.add(src);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[GzhPublish] content image upload failed for {}: {}", src, e.getMessage());
|
||||
failed.add(src);
|
||||
} finally {
|
||||
if (tmp != null) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
tmp.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ImageInlineResult(doc.body().html(), uploaded, failed);
|
||||
}
|
||||
|
||||
/** Resolve an article-body image ref to bytes: our generated files, or http(s). */
|
||||
private byte[] resolveImageBytes(String src) {
|
||||
try {
|
||||
Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(src);
|
||||
if (m.find()) {
|
||||
Optional<GeneratedFileCache.Entry> e = generatedFileCache.get(m.group(1));
|
||||
return e.map(GeneratedFileCache.Entry::bytes).orElse(null);
|
||||
}
|
||||
if (src.startsWith("http://") || src.startsWith("https://")) {
|
||||
UrlSafetyChecker.check(src);
|
||||
byte[] b = HttpUtil.downloadBytes(src);
|
||||
return (b != null && b.length > 0) ? b : null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("[GzhPublish] could not resolve body image {}: {}", src, e.toString());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String extOf(String url) {
|
||||
String clean = url.split("[?#]")[0];
|
||||
int dot = clean.lastIndexOf('.');
|
||||
if (dot >= 0 && dot < clean.length() - 1) {
|
||||
String ext = clean.substring(dot + 1).toLowerCase();
|
||||
if (ext.matches("(png|jpg|jpeg|gif|webp)")) {
|
||||
return ext.equals("jpeg") ? "jpg" : ext;
|
||||
}
|
||||
}
|
||||
return "jpg";
|
||||
}
|
||||
|
||||
/** Strip tags and clamp to a length for the article digest. */
|
||||
|
||||
@ -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)
|
||||
KEY (id)
|
||||
VALUES (1000000635, 'XhsPackageTool', 'Xiaohongshu Package', 'Package a Xiaohongshu (小红书) note into an image-first online preview (phone-style swipe: images up top, copy below) plus a material zip (numbered card images + copy.txt). Requires at least 3 vertical images (1 cover + >=2 content); refuses fewer. 小红书 has no publish API; never auto-uploads.', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0);
|
||||
|
||||
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
KEY (id)
|
||||
VALUES (1000000636, 'ContentItemTool', 'Content Calendar', 'Content calendar / dedup ledger: check_recent (has this topic run on this platform in the last N days — call before picking a topic), record (log a produced piece with title/preview/status), mark_published. Keeps the daily scheduler from repeating topics and makes publishing auditable.', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0);
|
||||
|
||||
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
KEY (id)
|
||||
VALUES (1000000637, 'ComplianceScanTool', 'Compliance Scan', 'Server-side compliance scan before publishing: 广告法 极限词, WeChat 诱导 words (集赞/助力/share-to-unlock/follow-to-read), promised returns, and medical-efficacy claims. Returns hits by category; the 公众号 draft path hard-blocks high-risk hits.', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0);
|
||||
|
||||
@ -1879,3 +1879,11 @@ ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000635, 'XhsPackageTool', 'Xiaohongshu Package', 'Package a Xiaohongshu (小红书) note into an image-first online preview (phone-style swipe: images up top, copy below) plus a material zip (numbered card images + copy.txt). Requires at least 3 vertical images (1 cover + >=2 content); refuses fewer. 小红书 has no publish API; never auto-uploads.', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
||||
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000636, 'ContentItemTool', 'Content Calendar', 'Content calendar / dedup ledger: check_recent (has this topic run on this platform in the last N days — call before picking a topic), record (log a produced piece with title/preview/status), mark_published. Keeps the daily scheduler from repeating topics and makes publishing auditable.', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
||||
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000637, 'ComplianceScanTool', 'Compliance Scan', 'Server-side compliance scan before publishing: 广告法 极限词, WeChat 诱导 words (集赞/助力/share-to-unlock/follow-to-read), promised returns, and medical-efficacy claims. Returns hits by category; the 公众号 draft path hard-blocks high-risk hits.', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
||||
|
||||
@ -1876,3 +1876,11 @@ ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000635, 'XhsPackageTool', '小红书打包', '把小红书笔记打包成在线预览(手机版滑动预览,以图为主、文字辅助)+ 素材下载 zip(编号卡片图 + 文案.txt)。强制至少 3 张竖版图(1 封面 + ≥2 内容图),不足则拒绝打包。小红书无发布 API,不自动上传。', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
||||
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000636, 'ContentItemTool', '内容日历', '内容日历 / 发布去重台账:check_recent 查最近 N 天某平台是否做过同题(选题前先查避免重复);record 记录产出(含标题/预览链接/状态);mark_published 标记为已发布。让每日定时不重复选题、发布可追溯。', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
||||
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000637, 'ComplianceScanTool', '合规扫描', '发布前服务端硬扫合规风险:广告法极限词(最/第一/唯一/国家级/100%)、微信诱导词(集赞/助力/分享解锁/关注才能看)、承诺收益、医疗功效。返回命中清单;公众号进草稿箱前对高危词硬拦截。', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, display_name=EXCLUDED.display_name, description=EXCLUDED.description, tool_type=EXCLUDED.tool_type, bean_name=EXCLUDED.bean_name, icon=EXCLUDED.icon, enabled=EXCLUDED.enabled, builtin=EXCLUDED.builtin, update_time=EXCLUDED.update_time, deleted=EXCLUDED.deleted;
|
||||
|
||||
@ -1995,3 +1995,11 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), de
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000635, 'XhsPackageTool', 'Xiaohongshu Package', 'Package a Xiaohongshu (小红书) note into an image-first online preview (phone-style swipe: images up top, copy below) plus a material zip (numbered card images + copy.txt). Requires at least 3 vertical images (1 cover + >=2 content); refuses fewer. 小红书 has no publish API; never auto-uploads.', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
|
||||
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000636, 'ContentItemTool', 'Content Calendar', 'Content calendar / dedup ledger: check_recent (has this topic run on this platform in the last N days — call before picking a topic), record (log a produced piece with title/preview/status), mark_published. Keeps the daily scheduler from repeating topics and makes publishing auditable.', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
|
||||
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000637, 'ComplianceScanTool', 'Compliance Scan', 'Server-side compliance scan before publishing: 广告法 极限词, WeChat 诱导 words (集赞/助力/share-to-unlock/follow-to-read), promised returns, and medical-efficacy claims. Returns hits by category; the 公众号 draft path hard-blocks high-risk hits.', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
|
||||
|
||||
@ -1992,3 +1992,11 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), de
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000635, 'XhsPackageTool', '小红书打包', '把小红书笔记打包成在线预览(手机版滑动预览,以图为主、文字辅助)+ 素材下载 zip(编号卡片图 + 文案.txt)。强制至少 3 张竖版图(1 封面 + ≥2 内容图),不足则拒绝打包。小红书无发布 API,不自动上传。', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
|
||||
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000636, 'ContentItemTool', '内容日历', '内容日历 / 发布去重台账:check_recent 查最近 N 天某平台是否做过同题(选题前先查避免重复);record 记录产出(含标题/预览链接/状态);mark_published 标记为已发布。让每日定时不重复选题、发布可追溯。', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
|
||||
|
||||
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
VALUES (1000000637, 'ComplianceScanTool', '合规扫描', '发布前服务端硬扫合规风险:广告法极限词(最/第一/唯一/国家级/100%)、微信诱导词(集赞/助力/分享解锁/关注才能看)、承诺收益、医疗功效。返回命中清单;公众号进草稿箱前对高危词硬拦截。', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0)
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted);
|
||||
|
||||
@ -1955,3 +1955,11 @@ VALUES (1000000634, 'ScreenshotTool', '后台截图', '截取 MateClaw 后台页
|
||||
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
KEY (id)
|
||||
VALUES (1000000635, 'XhsPackageTool', '小红书打包', '把小红书笔记打包成在线预览(手机版滑动预览,以图为主、文字辅助)+ 素材下载 zip(编号卡片图 + 文案.txt)。强制至少 3 张竖版图(1 封面 + ≥2 内容图),不足则拒绝打包。小红书无发布 API,不自动上传。', 'builtin', 'xhsPackageTool', '🖼️', TRUE, TRUE, NOW(), NOW(), 0);
|
||||
|
||||
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
KEY (id)
|
||||
VALUES (1000000636, 'ContentItemTool', '内容日历', '内容日历 / 发布去重台账:check_recent 查最近 N 天某平台是否做过同题(选题前先查避免重复);record 记录产出(含标题/预览链接/状态);mark_published 标记为已发布。让每日定时不重复选题、发布可追溯。', 'builtin', 'contentItemTool', '🗓️', TRUE, TRUE, NOW(), NOW(), 0);
|
||||
|
||||
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
|
||||
KEY (id)
|
||||
VALUES (1000000637, 'ComplianceScanTool', '合规扫描', '发布前服务端硬扫合规风险:广告法极限词(最/第一/唯一/国家级/100%)、微信诱导词(集赞/助力/分享解锁/关注才能看)、承诺收益、医疗功效。返回命中清单;公众号进草稿箱前对高危词硬拦截。', 'builtin', 'complianceScanTool', '🛡️', TRUE, TRUE, NOW(), NOW(), 0);
|
||||
|
||||
@ -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
|
||||
description: '公众号图文创作 / 推文 / 官方号文章 (official account article) — 端到端:选题→搜集→成文→配图→去AI化→公众号内联样式排版→交付/草稿箱。honors user persona & style memory.'
|
||||
version: 1.1.0
|
||||
version: 1.2.0
|
||||
tags:
|
||||
- 公众号
|
||||
- 图文
|
||||
@ -102,6 +102,14 @@ gzh_package(title="<标题>", markdown="<正文 Markdown,含小标题/列表/
|
||||
|
||||
`references/` 里的 `gzh_layout*.html` 是**服务端配色/排版的参考**(`gzh_package` 已内置同款风格);只有当用户明确要**高度定制的特殊版式**、且篇幅不大时,才手写内联 HTML 并用 `render_html_image(html=...)` 出预览图,注意控制体量避免截断。
|
||||
|
||||
## 定时 / 批量场景:内容日历 + 合规硬闸
|
||||
|
||||
长期投产(尤其每日定时)时,多接两步,避免重复选题和违规发布:
|
||||
|
||||
- **选题前查重**:`content_item(action="check_recent", platform="gzh", topic="<选题>")`。命中"疑似重复"就换角度或另选。
|
||||
- **发布前硬扫**:把标题+正文交给 `compliance_scan` 服务端扫一遍(极限词/诱导/承诺/功效)。命中高危词先替换——`gzh_publish` 进草稿箱时也会对高危词硬拦截。
|
||||
- **产出落台账**:`gzh_package` / `gzh_publish` 完成后 `content_item(action="record", platform="gzh", title, previewUrl, status="packaged"|"draft")`;真正发表后 `content_item(action="mark_published", id, externalRef)`。让每日不重题、发布可追溯。
|
||||
|
||||
## 保存自定义模板 / 对话升级技能
|
||||
|
||||
当用户对某个自创模板满意、想以后复用时,用 `skill_manage` 把它**存成一个自定义技能**(`builtin=false` 才能写入):
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
---
|
||||
name: xhs_note
|
||||
description: '小红书图文创作 / 笔记 / 种草文案 (xiaohongshu / red note) — 端到端:成文→配图(≥3 张竖版)→去AI化→在线预览打包交付。以图为主、文字辅助:标题四件套 + 碎句正文 + 话题标签,配 3:4 竖版卡片,最少 3 张图。honors user persona & style memory.'
|
||||
version: 1.1.0
|
||||
version: 1.2.0
|
||||
tags:
|
||||
- 小红书
|
||||
- 图文
|
||||
@ -116,6 +116,14 @@ xhs_package(title="<标题>", body="<正文,含 emoji 与换行>",
|
||||
|
||||
打包前对照 `banned_words` 扫一遍正文和标题,命中即标注替换。
|
||||
|
||||
## 定时 / 批量场景:内容日历 + 合规
|
||||
|
||||
长期投产(每日定时)时多接两步:
|
||||
|
||||
- **选题前查重**:`content_item(action="check_recent", platform="xhs", topic="<选题>")`,命中就换角度。
|
||||
- **打包前硬扫**:把标题+正文交给 `compliance_scan`(极限词/诱导/承诺/功效),命中先替换。
|
||||
- **产出落台账**:`xhs_package` 完成后 `content_item(action="record", platform="xhs", title, previewUrl, status="packaged")`;用户手动上传发布后 `content_item(action="mark_published", id)`。这样能知道哪些已发、哪些还在待办。
|
||||
|
||||
## 保存自定义卡片模板 / 对话升级技能
|
||||
|
||||
用户满意某个自创卡片、想复用时,用 `skill_manage` 存成**自定义技能**(`builtin=false` 才能写):
|
||||
|
||||
@ -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
|
||||
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) {
|
||||
|
||||
@ -68,7 +68,7 @@ class SystemSettingServiceCatalogTest {
|
||||
@DisplayName("marks builtin providers as builtin=true with no pluginName")
|
||||
void builtinEntry() {
|
||||
SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("serper", 300, true, false)));
|
||||
service = new SystemSettingService(mapper, registry, pluginManager);
|
||||
service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager);
|
||||
|
||||
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
|
||||
|
||||
@ -86,7 +86,7 @@ class SystemSettingServiceCatalogTest {
|
||||
SearchProviderRegistry registry = new SearchProviderRegistry(List.of());
|
||||
registry.registerPluginProvider(stub("my-search", 500, true, true));
|
||||
when(pluginManager.getPluginNameForSearchProvider("my-search")).thenReturn("my-plugin");
|
||||
service = new SystemSettingService(mapper, registry, pluginManager);
|
||||
service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager);
|
||||
|
||||
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
|
||||
|
||||
@ -105,7 +105,7 @@ class SystemSettingServiceCatalogTest {
|
||||
stub("duckduckgo", 100, false, true)));
|
||||
registry.registerPluginProvider(stub("my-search", 200, true, true));
|
||||
when(pluginManager.getPluginNameForSearchProvider("my-search")).thenReturn("my-plugin");
|
||||
service = new SystemSettingService(mapper, registry, pluginManager);
|
||||
service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager);
|
||||
|
||||
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
|
||||
|
||||
@ -128,7 +128,7 @@ class SystemSettingServiceCatalogTest {
|
||||
@DisplayName("surfaces the resolved provider id and source alongside the catalog")
|
||||
void resolvedSurfaced() {
|
||||
SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("duckduckgo", 100, false, true)));
|
||||
service = new SystemSettingService(mapper, registry, pluginManager);
|
||||
service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager);
|
||||
|
||||
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
|
||||
|
||||
@ -140,7 +140,7 @@ class SystemSettingServiceCatalogTest {
|
||||
@DisplayName("resolvedId/resolvedSource are null when no provider is available at all")
|
||||
void resolvedNullWhenNothingAvailable() {
|
||||
SearchProviderRegistry registry = new SearchProviderRegistry(List.of(stub("serper", 300, true, false)));
|
||||
service = new SystemSettingService(mapper, registry, pluginManager);
|
||||
service = new SystemSettingService(mapper, registry, new SettingCrypto("test-key"), pluginManager);
|
||||
|
||||
SearchProviderCatalogResponse catalog = service.getSearchProviderCatalog();
|
||||
|
||||
|
||||
@ -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
|
||||
@DisplayName("unresolvable cover → dropped with a warning, never a broken <img>")
|
||||
void unresolvableCoverDroppedAndWarned() {
|
||||
@DisplayName("unresolvable cover → placeholder cover + warning, never a broken <img>")
|
||||
void unresolvableCoverUsesPlaceholder() {
|
||||
String out = tool.gzh_package(
|
||||
"标题",
|
||||
BODY,
|
||||
@ -63,7 +63,9 @@ class GzhPackageCoverHealingTest {
|
||||
null);
|
||||
|
||||
assertTrue(out.contains("⚠️"), "an unresolved cover must be flagged; got:\n" + out);
|
||||
assertFalse(out.contains("<img "), "no broken cover image may be embedded");
|
||||
assertTrue(out.contains("占位封面"), "a placeholder cover must be substituted");
|
||||
assertTrue(out.contains("<img "), "the placeholder cover is embedded (never broken/absent)");
|
||||
assertFalse(out.contains("generated/deadbeef"), "the broken ref must not be embedded");
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -83,8 +85,8 @@ class GzhPackageCoverHealingTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a non-image generated file referenced as cover is not embedded")
|
||||
void nonImageReferenceNotEmbedded() {
|
||||
@DisplayName("a non-image generated file referenced as cover → placeholder, not the non-image")
|
||||
void nonImageReferenceFallsBackToPlaceholder() {
|
||||
String id = cache.put("%PDF".getBytes(), "handout.pdf", "application/pdf");
|
||||
String out = tool.gzh_package(
|
||||
"标题",
|
||||
@ -94,6 +96,7 @@ class GzhPackageCoverHealingTest {
|
||||
null);
|
||||
|
||||
assertTrue(out.contains("⚠️"), "a non-image cover must be flagged");
|
||||
assertFalse(out.contains("<img "), "a non-image must not be embedded as a cover");
|
||||
assertTrue(out.contains("占位封面"), "a placeholder cover must be substituted");
|
||||
assertFalse(out.contains("generated/" + id), "the non-image ref must not be embedded as the cover");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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