Add smart ACK provider for WeChat MP replies

Replace fixed ackText with a rule-based AckProvider that picks
response templates by message type and intent (translate, summary,
rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync,
zero I/O, auto-falls back to config.ackText on any error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
john
2026-06-26 15:19:03 +08:00
parent 9ed4fd48d7
commit 9b4a25799f
162 changed files with 17276 additions and 2054 deletions
+120
View File
@@ -20,6 +20,8 @@ const MAX_SUMMARY_LENGTH = 1000;
const MAX_CONTENT_BYTES = 1024 * 1024;
const TEMPLATE_IDS = new Set(['editorial', 'report', 'profile', 'knowledge-card', 'static-html']);
const SAVE_CATEGORY_CODES = new Set(['draft', 'oa', 'private', 'public']);
const PRIVATE_ASSET_URL_PATTERN =
/(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi;
function asNumber(value) {
return Number(value ?? 0);
@@ -75,6 +77,16 @@ function normalizePageInput(input) {
};
}
function extensionForMime(mimeType, filename = '') {
const ext = path.extname(String(filename ?? '')).replace(/^\./, '').toLowerCase();
if (ext && /^[a-z0-9]{1,8}$/.test(ext)) return ext === 'jpeg' ? 'jpg' : ext;
if (mimeType === 'image/jpeg') return 'jpg';
if (mimeType === 'image/png') return 'png';
if (mimeType === 'image/webp') return 'webp';
if (mimeType === 'image/gif') return 'gif';
return 'bin';
}
function pageResponse(row) {
return {
id: row.id,
@@ -657,6 +669,113 @@ export function createPageService(pool, options = {}) {
};
};
const localizePrivateResources = async (userId, pageId, input = {}) => {
const page = await getPage(userId, pageId);
if (input.pageVersionId && input.pageVersionId !== page.currentVersionId) {
throw pageError('只能基于当前版本修复页面', 'version_conflict', {
currentVersion: page.currentVersionId,
});
}
if (input.expectedVersion != null && asNumber(input.expectedVersion) !== asNumber(page.versionNo)) {
throw pageError('页面已被更新,请刷新后重试', 'version_conflict', {
currentVersion: asNumber(page.versionNo),
});
}
if (page.contentFormat !== 'html') {
throw pageError('该页面不支持资源本地化修复', 'unsupported_publish_fix');
}
const sourceTitle = input.title ?? page.title;
const sourceSummary = input.summary ?? page.summary ?? '';
const sourceContent = input.content ?? page.content ?? '';
const matches = [...String(sourceContent).matchAll(PRIVATE_ASSET_URL_PATTERN)];
if (matches.length === 0) {
throw pageError('未发现可安全修复的私有图片引用', 'publish_fix_not_needed');
}
const assetIds = [...new Set(matches.map((match) => match[1]).filter(Boolean))];
const [assets] = await pool.query(
`SELECT a.id, a.mime_type, a.original_filename, v.storage_key
FROM h5_assets a
JOIN h5_asset_versions v ON v.id = a.current_version_id
WHERE a.user_id = ? AND a.id IN (?) AND a.mime_type LIKE 'image/%'
AND a.status <> 'deleted'`,
[userId, assetIds],
);
const byId = new Map(assets.map((asset) => [asset.id, asset]));
const replacements = new Map();
for (const assetId of assetIds) {
const asset = byId.get(assetId);
if (!asset) continue;
const buffer = await fs.readFile(absoluteStoragePath(asset.storage_key));
const mimeType = String(asset.mime_type || 'application/octet-stream');
const ext = extensionForMime(mimeType, asset.original_filename);
const dataUri = `data:${mimeType};base64,${buffer.toString('base64')}`;
replacements.set(assetId, { dataUri, ext });
}
if (replacements.size === 0) {
throw pageError('私有资源不是可内联的图片,无法自动修复', 'unsupported_publish_fix');
}
let changedCount = 0;
const updatedContent = String(sourceContent).replace(
PRIVATE_ASSET_URL_PATTERN,
(value, assetId) => {
const replacement = replacements.get(assetId);
if (!replacement) return value;
changedCount += 1;
return replacement.dataUri;
},
);
if (updatedContent === sourceContent || changedCount === 0) {
throw pageError('未发现可安全修复的私有图片引用', 'publish_fix_not_needed');
}
const updated = await createVersion(
userId,
{
pageId,
expectedVersion: page.versionNo,
title: sourceTitle,
summary: sourceSummary,
content: updatedContent,
templateId: page.templateId,
pageType: page.pageType,
contentFormat: 'html',
changeNote: `一键发布修复 v${page.versionNo + 1}`,
},
{
type: 'generated',
snapshot: {
localized_private_resources: true,
},
},
);
return {
page: updated,
originalScan: scanContent(`${sourceTitle}\n${sourceSummary}\n${sourceContent}`, {
format: 'html',
}),
redactedScan: scanContent(
`${updated.title}\n${updated.summary ?? ''}\n${updated.content ?? ''}`,
{ format: 'html' },
),
redactionsApplied: changedCount,
changes: [
{
field: 'content',
fieldLabel: '正文',
type: 'private_resource_reference',
label: '私有资源引用',
count: changedCount,
},
],
};
};
const collectLinkedAssets = async (conn, userId, pageId) => {
const [versions] = await conn.query(
`SELECT pv.content_asset_id, pv.bundle_asset_id
@@ -1024,6 +1143,7 @@ export function createPageService(pool, options = {}) {
createPage: (userId, input) => createVersion(userId, input, { type: 'template' }),
updatePage: (userId, pageId, input) =>
createVersion(userId, { ...input, pageId }, { type: 'generated' }),
localizePrivateResources,
redactPage,
createRedactedCopy: redactPage,
listPages,