Files
memind/mindspace-pages.mjs
T
john 9b4a25799f 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>
2026-06-26 15:19:03 +08:00

1244 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { redactContent, scanContent } from './mindspace-content-scan.mjs';
import {
ensurePageThumbnail,
extractCoverSignals,
buildFeedThumbnailSvg,
generateHtmlThumbnail,
pageThumbnailKey,
writeThumbnail,
bufferToImageDataUri,
} from './mindspace-thumbnails.mjs';
import { resolvePublishDir } from './user-publish.mjs';
import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
import { upsertMindspaceCoverMeta } from './mindspace-cover-meta.mjs';
const MAX_TITLE_LENGTH = 255;
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);
}
function pageError(message, code, details) {
return Object.assign(new Error(message), { code, details });
}
function normalizeText(value, maxLength, fieldName) {
const text = String(value ?? '').normalize('NFKC').trim();
if (!text) throw pageError(`${fieldName}不能为空`, 'invalid_page_input');
if (text.length > maxLength) {
throw pageError(`${fieldName}超过长度限制`, 'invalid_page_input');
}
return text;
}
function normalizePageInput(input) {
const title = normalizeText(input.title, MAX_TITLE_LENGTH, '标题');
const content = normalizeText(input.content, MAX_CONTENT_BYTES, '页面内容');
const contentBytes = Buffer.byteLength(content, 'utf8');
if (contentBytes > MAX_CONTENT_BYTES) {
throw pageError('页面内容超过 1 MB 限制', 'page_content_too_large');
}
const summary = String(input.summary ?? '').normalize('NFKC').trim().slice(0, MAX_SUMMARY_LENGTH);
const contentFormat = input.contentFormat === 'html' ? 'html' : 'markdown';
const templateId =
contentFormat === 'html'
? 'static-html'
: TEMPLATE_IDS.has(input.templateId)
? input.templateId
: 'editorial';
const plainSummary =
contentFormat === 'html'
? content
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 180)
: content.replace(/\s+/g, ' ').slice(0, 180);
return {
title,
summary: summary || plainSummary,
content,
contentBytes,
templateId,
contentFormat,
pageType: contentFormat === 'html' ? 'html' : input.pageType || 'article',
categoryCode: SAVE_CATEGORY_CODES.has(input.categoryCode) ? input.categoryCode : 'draft',
};
}
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,
categoryId: row.category_id,
categoryCode: row.category_code,
sourceSessionId: row.source_session_id,
sourceMessageId: row.source_message_id,
sourceAssetId: row.source_asset_id,
title: row.title,
summary: row.summary ?? '',
pageType: row.page_type,
contentFormat: row.page_type === 'html' ? 'html' : 'markdown',
hasThumbnail: row.page_type === 'html',
templateId: row.template_id,
status: row.status,
visibility: row.visibility,
publicationAccessMode: row.pub_access_mode ?? null,
publicationUrl: row.pub_public_url ?? null,
currentVersionId: row.current_version_id,
versionNo: asNumber(row.version_no),
content: row.content,
createdAt: asNumber(row.created_at),
updatedAt: asNumber(row.updated_at),
};
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function renderContent(content) {
return content
.split(/\n{2,}/)
.map((block) => {
const trimmed = block.trim();
if (!trimmed) return '';
if (trimmed.startsWith('### ')) return `<h3>${escapeHtml(trimmed.slice(4))}</h3>`;
if (trimmed.startsWith('## ')) return `<h2>${escapeHtml(trimmed.slice(3))}</h2>`;
if (trimmed.startsWith('# ')) return `<h1>${escapeHtml(trimmed.slice(2))}</h1>`;
return `<p>${escapeHtml(trimmed).replaceAll('\n', '<br>')}</p>`;
})
.join('\n');
}
function renderPreviewHtml(page) {
const templateClass = TEMPLATE_IDS.has(page.templateId) ? page.templateId : 'editorial';
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'">
<title>${escapeHtml(page.title)}</title>
<style>
:root { color: #18211d; background: #f5f0e5; font-family: Georgia, "Times New Roman", serif; }
* { box-sizing: border-box; }
body { margin: 0; padding: clamp(24px, 7vw, 88px); background: radial-gradient(circle at 90% 0, #d8e8df, transparent 34rem), #f5f0e5; }
article { width: min(820px, 100%); margin: 0 auto; padding: clamp(28px, 6vw, 72px); border: 1px solid #d7d0c1; border-radius: 28px; background: rgba(255,253,247,.94); box-shadow: 0 24px 70px rgba(45,53,47,.12); }
.label { color: #9b6518; font: 800 12px/1.4 ui-sans-serif, sans-serif; letter-spacing: .18em; }
h1 { margin: 12px 0 18px; font-size: clamp(40px, 8vw, 76px); line-height: 1; letter-spacing: -.045em; }
.summary { color: #5c655f; font: 18px/1.7 ui-sans-serif, sans-serif; }
.content { margin-top: 42px; font-size: 20px; line-height: 1.85; }
.content h1, .content h2, .content h3 { margin-top: 1.4em; font-size: 1.55em; letter-spacing: -.02em; }
.content p { margin: 0 0 1.2em; }
.report article { border-top: 8px solid #2f6f57; }
.knowledge-card article { max-width: 680px; background: linear-gradient(145deg, #fffaf0, #e4eddf); }
@media (max-width: 560px) { body { padding: 12px; } article { border-radius: 20px; } .content { font-size: 17px; } }
</style>
</head>
<body class="${templateClass}">
<article>
<div class="label">MINDSPACE DRAFT · V${page.versionNo}</div>
<h1>${escapeHtml(page.title)}</h1>
<div class="summary">${escapeHtml(page.summary)}</div>
<div class="content">${renderContent(page.content)}</div>
</article>
</body>
</html>`;
}
function renderHtmlPreview(html) {
const csp =
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\' https:; img-src data: https:; font-src https: data:; base-uri \'none\'; form-action \'none\'; script-src \'none\'">';
const previewShell =
'<style id="mindspace-preview-shell">html,body{margin:0;padding:0;background:#f5f0e5}body{min-height:100%}</style>';
const source = String(html);
if (/<head[^>]*>/i.test(source)) {
return source.replace(/<head([^>]*)>/i, `<head$1>${csp}${previewShell}`);
}
if (/<html[^>]*>/i.test(source)) {
return source.replace(/<html([^>]*)>/i, `<html$1><head>${csp}${previewShell}</head>`);
}
return `<!doctype html><html lang="zh-CN"><head>${csp}${previewShell}</head><body>${source}</body></html>`;
}
export function previewContentSecurityPolicy(contentFormat) {
if (contentFormat === 'html') {
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src data: https:; font-src https: data:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; script-src 'none'";
}
return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'";
}
function isHtmlPage(page) {
return page.pageType === 'html' || page.page_type === 'html' || page.contentFormat === 'html';
}
function renderPublicationHtml(page) {
if (isHtmlPage(page)) {
return renderHtmlPreview(page.content);
}
return renderPreviewHtml({
title: page.title,
summary: page.summary ?? '',
templateId: page.template_id,
versionNo: page.version_no,
content: page.content,
}).replace(
`MINDSPACE DRAFT · V${page.version_no}`,
`MINDSPACE · V${page.version_no}`,
);
}
export function createPageService(pool, options = {}) {
const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace'));
const h5Root = options.h5Root ? path.resolve(options.h5Root) : null;
const idFactory = options.idFactory ?? (() => crypto.randomUUID());
const resolveWorkspacePublishDir = async (userId) => {
if (!h5Root) return null;
const [rows] = await pool.query(
`SELECT username FROM h5_users WHERE id = ? LIMIT 1`,
[userId],
);
const username = rows[0]?.username;
if (!username) return null;
return resolvePublishDir(h5Root, { id: userId });
};
const absoluteStoragePath = (storageKey) => {
const resolved = path.resolve(storageRoot, storageKey);
if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) {
throw new Error('存储路径越界');
}
return resolved;
};
const resolveStoragePathCandidates = (storageKey) => {
const normalized = String(storageKey ?? '').replace(/\\/g, '/');
const withoutMd = normalized.endsWith('.md') ? normalized.slice(0, -3) : normalized;
const match = withoutMd.match(/^users\/([^/]+)\/(assets|pages)\/([^/]+)\/(v\d+)$/);
if (!match) return [normalized];
const [, userId, scope, entityId, versionTag] = match;
return [
normalized,
`users/${userId}/${scope}/${entityId}/versions/${versionTag}.md`,
`users/${userId}/${scope}/${entityId}/versions/${versionTag}`,
].filter((candidate, index, list) => list.indexOf(candidate) === index);
};
const resolveReadableStoragePath = async (storageKey) => {
let lastError = null;
for (const candidate of resolveStoragePathCandidates(storageKey)) {
const absolutePath = absoluteStoragePath(candidate);
try {
await fs.stat(absolutePath);
return absolutePath;
} catch (error) {
if (error?.code === 'ENOENT') {
lastError = error;
continue;
}
throw error;
}
}
throw lastError ?? Object.assign(new Error('存储文件不存在'), { code: 'storage_not_found' });
};
const writeVersionContent = async (userId, assetId, versionId, content) => {
const storageKey = path.posix.join(
'users',
userId,
'pages',
assetId,
'versions',
`${versionId}.md`,
);
const target = absoluteStoragePath(storageKey);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, content, { flag: 'wx' });
return { storageKey, target };
};
const createVersion = async (userId, input, source = {}) => {
let pageInput = input;
if (input.pageId && input.contentFormat == null) {
const [existingPages] = await pool.query(
`SELECT page_type FROM h5_page_records WHERE id = ? AND user_id = ? AND status <> 'deleted' LIMIT 1`,
[input.pageId, userId],
);
if (existingPages[0]?.page_type === 'html') {
pageInput = { ...input, contentFormat: 'html' };
}
}
const normalized = normalizePageInput(pageInput);
const conn = await pool.getConnection();
let writtenPath;
try {
await conn.beginTransaction();
const [spaces] = await conn.query(
`SELECT id, quota_bytes, used_bytes, reserved_bytes, status
FROM h5_user_spaces WHERE user_id = ? LIMIT 1 FOR UPDATE`,
[userId],
);
const space = spaces[0];
if (!space || space.status !== 'active') {
throw pageError('用户空间不可用', 'space_unavailable');
}
const available =
asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
if (available < normalized.contentBytes) {
throw pageError('剩余空间不足', 'quota_exceeded', {
requiredBytes: normalized.contentBytes,
availableBytes: Math.max(0, available),
});
}
const [categories] = await conn.query(
`SELECT id, category_code FROM h5_space_categories
WHERE space_id = ? AND user_id = ? AND category_code = ?
LIMIT 1 FOR UPDATE`,
[space.id, userId, normalized.categoryCode],
);
const category = categories[0];
if (!category) throw pageError('目标分类不存在', 'category_not_found');
if (normalized.categoryCode !== 'draft') {
throw pageError('页面记录只能保存在页面草稿区', 'category_not_pageable');
}
let pageId = input.pageId;
let nextVersionNo = 1;
if (pageId) {
const [pages] = await conn.query(
`SELECT p.id, p.current_version_id, pv.version_no
FROM h5_page_records p
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
LIMIT 1 FOR UPDATE`,
[pageId, userId],
);
const page = pages[0];
if (!page) throw pageError('页面不存在', 'page_not_found');
if (asNumber(input.expectedVersion) !== asNumber(page.version_no)) {
throw pageError('页面已被更新,请刷新后重试', 'version_conflict', {
currentVersion: asNumber(page.version_no),
});
}
nextVersionNo = asNumber(page.version_no) + 1;
} else {
pageId = idFactory();
}
const contentAssetId = idFactory();
const assetVersionId = idFactory();
const pageVersionId = idFactory();
const checksum = crypto.createHash('sha256').update(normalized.content).digest('hex');
const stored = await writeVersionContent(
userId,
contentAssetId,
assetVersionId,
normalized.content,
);
writtenPath = stored.target;
const now = Date.now();
const contentAssetType = normalized.contentFormat === 'html' ? 'html' : 'markdown';
const contentMimeType =
normalized.contentFormat === 'html' ? 'text/html' : 'text/markdown';
const contentExtension = normalized.contentFormat === 'html' ? 'html' : 'md';
await conn.query(
`INSERT INTO h5_assets
(id, user_id, space_id, category_id, asset_type, mime_type, original_filename,
display_name, current_version_id, size_bytes, checksum, risk_level, visibility,
status, source_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'none',
'private', 'ready', ?, ?, ?)`,
[
contentAssetId,
userId,
space.id,
category.id,
contentAssetType,
contentMimeType,
`${pageId}-v${nextVersionNo}.${contentExtension}`,
`${normalized.title} · v${nextVersionNo}`,
assetVersionId,
normalized.contentBytes,
checksum,
source.type ?? 'generated',
now,
now,
],
);
await conn.query(
`INSERT INTO h5_asset_versions
(id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type,
created_by, change_note, scan_status, created_at)
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, 'pending', ?)`,
[
assetVersionId,
contentAssetId,
stored.storageKey,
normalized.contentBytes,
checksum,
contentMimeType,
userId,
input.changeNote ?? '保存页面草稿',
now,
],
);
if (nextVersionNo === 1) {
await conn.query(
`INSERT INTO h5_page_records
(id, user_id, space_id, category_id, source_session_id, source_message_id,
source_asset_id, title, summary, page_type, template_id, draft_content_ref,
current_version_id, status, visibility, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 'private', ?, ?)`,
[
pageId,
userId,
space.id,
category.id,
source.sessionId ?? null,
source.messageId ?? null,
source.assetId ?? null,
normalized.title,
normalized.summary,
normalized.pageType,
normalized.templateId,
stored.storageKey,
pageVersionId,
now,
now,
],
);
}
await conn.query(
`INSERT INTO h5_page_versions
(id, page_id, version_no, content_asset_id, source_snapshot_json,
created_by, change_note, immutable, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)`,
[
pageVersionId,
pageId,
nextVersionNo,
contentAssetId,
JSON.stringify(source.snapshot ?? {}),
userId,
input.changeNote ?? '保存页面草稿',
now,
],
);
if (nextVersionNo > 1) {
await conn.query(
`UPDATE h5_page_records
SET title = ?, summary = ?, page_type = ?, template_id = ?,
draft_content_ref = ?, current_version_id = ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[
normalized.title,
normalized.summary,
normalized.pageType,
normalized.templateId,
stored.storageKey,
pageVersionId,
now,
pageId,
userId,
],
);
}
await conn.query(
`UPDATE h5_user_spaces SET used_bytes = used_bytes + ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[normalized.contentBytes, now, space.id, userId],
);
await conn.commit();
if (normalized.contentFormat === 'html') {
const workspaceHtmlRelativePath = source.snapshot?.relative_path ?? null;
const workspacePublishDir = workspaceHtmlRelativePath
? await resolveWorkspacePublishDir(userId)
: null;
if (workspacePublishDir && workspaceHtmlRelativePath) {
await ensureWorkspaceHtmlThumbnail(
workspacePublishDir,
workspaceHtmlRelativePath,
normalized.content,
{
title: normalized.title,
subtitle: normalized.summary || `草稿 v${nextVersionNo}`,
contentStorageKey: stored.storageKey,
},
).catch(() => {});
}
const thumbMeta = {
title: normalized.title,
subtitle: normalized.summary || `草稿 v${nextVersionNo}`,
contentStorageKey: stored.storageKey,
};
queueMicrotask(() => {
void ensurePageThumbnail({
storageRoot,
pageThumbnailStorageKey: pageThumbnailKey(userId, pageId),
html: normalized.content,
meta: thumbMeta,
workspacePublishDir,
workspaceHtmlRelativePath,
}).catch(() => {});
});
}
return getPage(userId, pageId);
} catch (error) {
await conn.rollback();
if (writtenPath) await fs.rm(writtenPath, { force: true }).catch(() => {});
throw error;
} finally {
conn.release();
}
};
const findPageBySourceAsset = async (userId, assetId) => {
if (!assetId) return null;
const [rows] = await pool.query(
`SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
FROM h5_page_records p
JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'
WHERE p.user_id = ? AND p.source_asset_id = ? AND p.status <> 'deleted'
ORDER BY p.updated_at DESC
LIMIT 1`,
[userId, assetId],
);
return rows[0] ? pageResponse(rows[0]) : null;
};
const listPages = async (userId, filters = {}) => {
const clauses = [`p.user_id = ?`, `p.status <> 'deleted'`];
const params = [userId];
if (filters.status) {
clauses.push(`p.status = ?`);
params.push(filters.status);
}
const [rows] = await pool.query(
`SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
FROM h5_page_records p
JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'
WHERE ${clauses.join(' AND ')}
ORDER BY p.updated_at DESC LIMIT 100`,
params,
);
return rows.map(pageResponse);
};
async function getPage(userId, pageId) {
const [rows] = await pool.query(
`SELECT p.*, c.category_code, pv.version_no, av.storage_key
FROM h5_page_records p
JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
JOIN h5_page_versions pv ON pv.id = p.current_version_id
JOIN h5_asset_versions av ON av.asset_id = pv.content_asset_id AND av.version_no = 1
WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
LIMIT 1`,
[pageId, userId],
);
const row = rows[0];
if (!row) throw pageError('页面不存在', 'page_not_found');
const storagePath = await resolveReadableStoragePath(row.storage_key);
const content = await fs.readFile(storagePath, 'utf8');
return pageResponse({ ...row, content });
}
const listVersions = async (userId, pageId) => {
await getPage(userId, pageId);
const [rows] = await pool.query(
`SELECT pv.id, pv.version_no, pv.change_note, pv.immutable, pv.created_at
FROM h5_page_versions pv
JOIN h5_page_records p ON p.id = pv.page_id
WHERE pv.page_id = ? AND p.user_id = ?
ORDER BY pv.version_no DESC`,
[pageId, userId],
);
return rows.map((row) => ({
id: row.id,
versionNo: asNumber(row.version_no),
changeNote: row.change_note,
immutable: Boolean(row.immutable),
createdAt: asNumber(row.created_at),
}));
};
const redactPage = 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),
});
}
const contentFormat = page.contentFormat === 'html' ? 'html' : 'markdown';
const scanFormat = contentFormat === 'html' ? 'html' : 'text';
const sourceTitle = input.title ?? page.title;
const sourceSummary = input.summary ?? page.summary ?? '';
const sourceContent = input.content ?? page.content ?? '';
const titleRedaction = redactContent(sourceTitle, { format: 'text' });
const summaryRedaction = redactContent(sourceSummary, { format: 'text' });
const contentRedaction = redactContent(sourceContent, { format: scanFormat });
const fieldChanges = (field, fieldLabel, redaction) =>
redaction.findings.map((finding) => ({
field,
fieldLabel,
type: finding.type,
label: finding.label,
count: finding.occurrenceCount,
}));
const changes = [
...fieldChanges('title', '标题', titleRedaction),
...fieldChanges('summary', '摘要', summaryRedaction),
...fieldChanges('content', '正文', contentRedaction),
];
const redactionsApplied = changes.reduce((total, change) => total + change.count, 0);
if (redactionsApplied === 0) {
throw pageError('未发现需要修复的风险内容', 'redaction_not_needed');
}
const updated = await createVersion(
userId,
{
pageId,
expectedVersion: page.versionNo,
title: titleRedaction.content,
summary: summaryRedaction.content.trim() || sourceSummary,
content: contentRedaction.content,
templateId: page.templateId,
pageType: page.pageType,
contentFormat,
changeNote: `一键修复 v${page.versionNo + 1}`,
},
{
type: 'generated',
snapshot: {
desensitized: true,
},
},
);
return {
page: updated,
originalScan: scanContent(`${sourceTitle}\n${sourceSummary}\n${sourceContent}`, {
format: scanFormat,
}),
redactedScan: scanContent(
`${updated.title}\n${updated.summary ?? ''}\n${updated.content ?? ''}`,
{ format: scanFormat },
),
redactionsApplied,
changes,
};
};
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
FROM h5_page_versions pv
WHERE pv.page_id = ?`,
[pageId],
);
const assetKind = new Map();
for (const version of versions) {
if (version.content_asset_id) assetKind.set(version.content_asset_id, 'content');
if (version.bundle_asset_id) assetKind.set(version.bundle_asset_id, 'bundle');
}
const assetIds = [...assetKind.keys()];
if (assetIds.length === 0) {
return { assets: [], totalBytes: 0, assetIds: [] };
}
const [assets] = await conn.query(
`SELECT id, display_name, size_bytes, status
FROM h5_assets
WHERE user_id = ? AND id IN (?) AND status <> 'deleted'`,
[userId, assetIds],
);
const linkedAssets = assets.map((asset) => ({
id: asset.id,
displayName: asset.display_name,
sizeBytes: asNumber(asset.size_bytes),
kind: assetKind.get(asset.id) ?? 'content',
}));
return {
assets: linkedAssets,
totalBytes: linkedAssets.reduce((sum, asset) => sum + asset.sizeBytes, 0),
assetIds: linkedAssets.map((asset) => asset.id),
};
};
const offlineOnlinePublications = async (conn, userId, pageId, now) => {
const [rows] = await conn.query(
`SELECT id, page_version_id, access_mode
FROM h5_publish_records
WHERE page_id = ? AND user_id = ? AND status = 'online'
FOR UPDATE`,
[pageId, userId],
);
for (const publication of rows) {
await conn.query(
`UPDATE h5_publish_records
SET status = 'offline', offline_at = ?, updated_at = ?
WHERE id = ?`,
[now, now, publication.id],
);
await conn.query(
`INSERT INTO h5_publication_events
(id, publish_id, event_type, actor_id, old_page_version_id, new_page_version_id,
access_mode, detail_json, created_at)
VALUES (?, ?, 'offlined', ?, ?, NULL, ?, ?, ?)`,
[
idFactory(),
publication.id,
userId,
publication.page_version_id,
publication.access_mode,
JSON.stringify({ reason: 'page_deleted' }),
now,
],
);
}
return rows.length;
};
const softDeleteLinkedAssets = async (conn, userId, spaceId, assetIds, now) => {
let freedBytes = 0;
let deletedAssetCount = 0;
for (const assetId of assetIds) {
const [rows] = await conn.query(
`SELECT id, size_bytes
FROM h5_assets
WHERE id = ? AND user_id = ? AND status <> 'deleted'
LIMIT 1 FOR UPDATE`,
[assetId, userId],
);
const asset = rows[0];
if (!asset) continue;
await conn.query(
`UPDATE h5_assets SET status = 'deleted', deleted_at = ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[now, now, assetId, userId],
);
freedBytes += asNumber(asset.size_bytes);
deletedAssetCount += 1;
}
if (freedBytes > 0) {
await conn.query(
`UPDATE h5_user_spaces
SET used_bytes = GREATEST(0, used_bytes - ?), updated_at = ?
WHERE id = ? AND user_id = ?`,
[freedBytes, now, spaceId, userId],
);
}
return { deletedAssetCount, freedBytes };
};
const getDeletePreview = async (userId, pageId) => {
const [rows] = await pool.query(
`SELECT p.id, p.title, p.source_asset_id, p.status
FROM h5_page_records p
WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
LIMIT 1`,
[pageId, userId],
);
const page = rows[0];
if (!page) throw pageError('页面不存在', 'page_not_found');
const conn = await pool.getConnection();
try {
const [versionRows] = await conn.query(
`SELECT COUNT(*) AS version_count FROM h5_page_versions WHERE page_id = ?`,
[pageId],
);
const { assets, totalBytes } = await collectLinkedAssets(conn, userId, pageId);
const [publications] = await conn.query(
`SELECT id, status, public_url, access_mode, view_count
FROM h5_publish_records
WHERE page_id = ? AND user_id = ?
ORDER BY published_at DESC`,
[pageId, userId],
);
const [agentJobs] = await conn.query(
`SELECT COUNT(*) AS job_count
FROM h5_agent_jobs
WHERE user_id = ? AND result_page_id = ?`,
[userId, pageId],
);
const onlinePublication = publications.find((item) => item.status === 'online') ?? null;
const offlinePublicationCount = publications.filter((item) => item.status !== 'online').length;
const preview = {
page: {
id: page.id,
title: page.title,
status: page.status,
versionCount: asNumber(versionRows[0]?.version_count),
},
onlinePublication: onlinePublication
? {
id: onlinePublication.id,
publicUrl: onlinePublication.public_url,
accessMode: onlinePublication.access_mode,
viewCount: asNumber(onlinePublication.view_count),
}
: null,
offlinePublicationCount,
linkedAssets: assets,
linkedAssetBytes: totalBytes,
linkedAgentJobCount: asNumber(agentJobs[0]?.job_count),
preservesSourceAsset: Boolean(page.source_asset_id),
};
return { ...preview, summaryLines: buildPageDeleteSummary(preview) };
} finally {
conn.release();
}
};
const deletePage = async (userId, pageId) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [pages] = await conn.query(
`SELECT id, space_id, title
FROM h5_page_records
WHERE id = ? AND user_id = ? AND status <> 'deleted'
LIMIT 1 FOR UPDATE`,
[pageId, userId],
);
const page = pages[0];
if (!page) throw pageError('页面不存在', 'page_not_found');
const now = Date.now();
const { assetIds } = await collectLinkedAssets(conn, userId, pageId);
const offlinedPublicationCount = await offlineOnlinePublications(conn, userId, pageId, now);
const { deletedAssetCount, freedBytes } = await softDeleteLinkedAssets(
conn,
userId,
page.space_id,
assetIds,
now,
);
const [agentJobRows] = await conn.query(
`SELECT COUNT(*) AS job_count
FROM h5_agent_jobs
WHERE user_id = ? AND result_page_id = ?`,
[userId, pageId],
);
const clearedAgentJobCount = asNumber(agentJobRows[0]?.job_count);
await conn.query(
`UPDATE h5_agent_jobs
SET result_page_id = NULL, updated_at = ?
WHERE user_id = ? AND result_page_id = ?`,
[now, userId, pageId],
);
await conn.query(
`UPDATE h5_page_records
SET status = 'deleted', deleted_at = ?, updated_at = ?,
current_publish_id = NULL, visibility = 'private'
WHERE id = ? AND user_id = ?`,
[now, now, pageId, userId],
);
await conn.commit();
return {
deleted: true,
pageId,
title: page.title,
offlinedPublicationCount,
deletedAssetCount,
freedBytes,
clearedAgentJobCount,
};
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
};
const loadPageThumbnailContext = async (userId, pageId) => {
const [rows] = await pool.query(
`SELECT p.title, p.summary, p.page_type, pv.version_no, av.storage_key, pv.source_snapshot_json
FROM h5_page_records p
JOIN h5_page_versions pv ON pv.id = p.current_version_id
JOIN h5_asset_versions av ON av.asset_id = pv.content_asset_id AND av.version_no = 1
WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
LIMIT 1`,
[pageId, userId],
);
const row = rows[0];
if (!row) throw pageError('页面不存在', 'page_not_found');
if (row.page_type !== 'html') {
throw pageError('该页面不支持缩略图', 'thumbnail_not_supported');
}
const storagePath = await resolveReadableStoragePath(row.storage_key);
const content = await fs.readFile(storagePath, 'utf8');
let snapshot = {};
try {
snapshot = JSON.parse(row.source_snapshot_json ?? '{}');
} catch {
snapshot = {};
}
const workspaceHtmlRelativePath = snapshot.relative_path ?? null;
const workspacePublishDir = workspaceHtmlRelativePath
? await resolveWorkspacePublishDir(userId)
: null;
return {
row,
content,
workspaceHtmlRelativePath,
workspacePublishDir,
thumbnailStorageKey: pageThumbnailKey(userId, pageId),
};
};
const syncWorkspaceThumbnail = async ({
workspacePublishDir,
workspaceHtmlRelativePath,
html,
meta,
}) => {
if (!workspacePublishDir || !workspaceHtmlRelativePath) return;
await ensureWorkspaceHtmlThumbnail(
workspacePublishDir,
workspaceHtmlRelativePath,
html,
{ ...meta, force: true },
).catch(() => {});
};
const uploadThumbnail = async (userId, pageId, input = {}) => {
const imageBase64 = String(input.imageBase64 ?? '').trim();
if (!imageBase64) throw pageError('请上传图片', 'thumbnail_image_required');
const buffer = Buffer.from(imageBase64, 'base64');
const coverDataUri = bufferToImageDataUri(buffer);
if (!coverDataUri) throw pageError('图片无效或过大', 'thumbnail_image_invalid');
const ctx = await loadPageThumbnailContext(userId, pageId);
const html = String(input.html ?? ctx.content);
const title = String(input.title ?? ctx.row.title ?? '').trim() || ctx.row.title;
const summary = String(input.summary ?? ctx.row.summary ?? '').trim();
const signals = extractCoverSignals(html, {
title,
subtitle: summary || `草稿 v${asNumber(ctx.row.version_no)}`,
});
const svg = buildFeedThumbnailSvg(signals, { coverDataUri });
await writeThumbnail(storageRoot, ctx.thumbnailStorageKey, svg);
if (ctx.workspacePublishDir && ctx.workspaceHtmlRelativePath) {
await writeThumbnail(
ctx.workspacePublishDir,
workspaceThumbnailRelativePath(ctx.workspaceHtmlRelativePath),
svg,
);
}
return { updatedAt: Date.now() };
};
const regenerateThumbnail = async (userId, pageId, input = {}, options = {}) => {
const ctx = await loadPageThumbnailContext(userId, pageId);
let html = String(input.html ?? ctx.content);
const title = String(input.title ?? ctx.row.title ?? '').trim() || ctx.row.title;
const summary = String(input.summary ?? ctx.row.summary ?? '').trim();
let updatedContent = null;
let metaOverrides = {};
if (input.useAi) {
if (!options.suggestCoverMeta) {
throw pageError('AI 封面生成未启用', 'cover_ai_unavailable');
}
metaOverrides = await options.suggestCoverMeta({
title,
summary,
html,
instruction: input.instruction,
});
updatedContent = upsertMindspaceCoverMeta(html, metaOverrides);
html = updatedContent;
}
const meta = {
title,
subtitle: summary || `草稿 v${asNumber(ctx.row.version_no)}`,
contentStorageKey: ctx.row.storage_key,
force: true,
...metaOverrides,
};
await generateHtmlThumbnail(storageRoot, ctx.thumbnailStorageKey, html, meta);
await syncWorkspaceThumbnail({
workspacePublishDir: ctx.workspacePublishDir,
workspaceHtmlRelativePath: ctx.workspaceHtmlRelativePath,
html,
meta,
});
return {
updatedAt: Date.now(),
content: updatedContent,
};
};
return {
createFromChat: (userId, input, source) =>
createVersion(userId, input, {
type: 'chat',
sessionId: source.sessionId,
messageId: source.messageId,
assetId: source.assetId ?? null,
snapshot: source.snapshot,
}),
createFromAgent: (userId, input, source) =>
createVersion(userId, input, {
type: 'agent',
assetId: source.assetId ?? null,
snapshot: {
job_id: source.jobId ?? null,
source_asset_ids: source.assetIds ?? [],
},
}),
createPage: (userId, input) => createVersion(userId, input, { type: 'template' }),
updatePage: (userId, pageId, input) =>
createVersion(userId, { ...input, pageId }, { type: 'generated' }),
localizePrivateResources,
redactPage,
createRedactedCopy: redactPage,
listPages,
findPageBySourceAsset,
getPage,
getDeletePreview,
deletePage,
listVersions,
renderPreview: async (userId, pageId) => {
const page = await getPage(userId, pageId);
const html =
page.contentFormat === 'html'
? renderHtmlPreview(page.content)
: renderPreviewHtml(page);
return { html, contentFormat: page.contentFormat };
},
renderDraftPreview: async (userId, pageId, input = {}) => {
const page = await getPage(userId, pageId);
const title = String(input.title ?? page.title ?? '').trim() || page.title;
const summary = String(input.summary ?? page.summary ?? '').trim();
const content = String(input.content ?? page.content ?? '');
const templateId = input.templateId ?? page.templateId;
const html =
page.contentFormat === 'html'
? renderHtmlPreview(content)
: renderPreviewHtml({
title,
summary,
templateId,
versionNo: page.versionNo,
content,
});
return { html, contentFormat: page.contentFormat };
},
renderThumbnail: async (userId, pageId) => {
const ctx = await loadPageThumbnailContext(userId, pageId);
return ensurePageThumbnail({
storageRoot,
pageThumbnailStorageKey: ctx.thumbnailStorageKey,
html: ctx.content,
meta: {
title: ctx.row.title,
subtitle: ctx.row.summary || `草稿 v${asNumber(ctx.row.version_no)}`,
contentStorageKey: ctx.row.storage_key,
},
workspacePublishDir: ctx.workspacePublishDir,
workspaceHtmlRelativePath: ctx.workspaceHtmlRelativePath,
});
},
uploadThumbnail,
regenerateThumbnail,
};
}
export const pageInternals = {
escapeHtml,
normalizePageInput,
renderContent,
renderPreviewHtml,
renderHtmlPreview,
previewContentSecurityPolicy,
isHtmlPage,
renderPublicationHtml,
redactContent,
buildPageDeleteSummary,
};
export function buildPageDeleteSummary(preview) {
const lines = [
`页面「${preview.page.title}」及其 ${preview.page.versionCount} 个版本`,
];
if (preview.onlinePublication) {
lines.push(`在线公开链接将立即下线:${preview.onlinePublication.publicUrl}`);
}
if (preview.offlinePublicationCount > 0) {
lines.push(`${preview.offlinePublicationCount} 条历史发布记录将保留审计信息,但不再可访问`);
}
if (preview.linkedAssets.length > 0) {
lines.push(
`${preview.linkedAssets.length} 个页面内容资产将被删除(约 ${formatByteSize(preview.linkedAssetBytes)}`,
);
}
if (preview.linkedAgentJobCount > 0) {
lines.push(`${preview.linkedAgentJobCount} 个 Agent 任务结果关联将被清除`);
}
if (preview.preservesSourceAsset) {
lines.push('原始上传资料不会被删除');
}
return lines;
}
function formatByteSize(bytes) {
const value = asNumber(bytes);
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(value % 1024 === 0 ? 0 : 1)} KB`;
return `${(value / (1024 * 1024)).toFixed(value % (1024 * 1024) === 0 ? 0 : 1)} MB`;
}