Files
memind/mindspace-pages.mjs
T
john e3063ea806 fix(mindspace): refresh published HTML after workspace sync
Keep online publication snapshots aligned when public/*.html changes, default static page binds to public access with MindSpace delivery URLs, and split browser-safe workspace helpers out of the Vite import chain.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 20:44:04 +08:00

1683 lines
60 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 { normalizeWorkspaceRelativePath } from './mindspace-workspace-relative-path.mjs';
import {
ensurePageThumbnail,
extractCoverSignals,
buildFeedThumbnailSvg,
generateHtmlThumbnail,
pageThumbnailKey,
writeThumbnail,
bufferToImageDataUri,
} from './mindspace-thumbnails.mjs';
import { resolvePublishDir } from './user-publish.mjs';
import { prepareHtmlDownloadLinks, inferWorkspaceHtmlRelativePath } from './mindspace-html-download-links.mjs';
import { prepareHtmlPageBrandMarkers } from './mindspace-page-tag.mjs';
import { purgeWorkspacePageArtifacts, extractAssetIdsFromHtml } from './mindspace-page-purge.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);
}
export function parseJsonColumn(value, fallback = {}) {
if (value == null || value === '') return { ...fallback };
if (typeof value === 'object' && !Buffer.isBuffer(value)) return value;
try {
return JSON.parse(String(value));
} catch {
return { ...fallback };
}
}
function pageError(message, code, details) {
return Object.assign(new Error(message), { code, details });
}
export { normalizeWorkspaceRelativePath } from './mindspace-workspace-relative-path.mjs';
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, '标题');
let content = normalizeText(input.content, MAX_CONTENT_BYTES, '页面内容');
const contentFormat = input.contentFormat === 'html' ? 'html' : 'markdown';
if (contentFormat === 'html') {
content = prepareHtmlPageBrandMarkers(content);
}
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 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, { includeContent = true } = {}) {
const response = {
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),
createdAt: asNumber(row.created_at),
updatedAt: asNumber(row.updated_at),
};
if (includeContent) {
response.content = row.content;
}
return response;
}
const LIST_PAGES_MAX_LIMIT = 100;
function normalizeListPageFilters(filters = {}) {
const limit = Math.min(Math.max(Number(filters.limit) || LIST_PAGES_MAX_LIMIT, 1), LIST_PAGES_MAX_LIMIT);
const offset = Math.max(Number(filters.offset) || 0, 0);
return { limit, offset };
}
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: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-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 conversationPackageRegistry = options.conversationPackageRegistry ?? null;
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' });
};
// REGRESSION GUARD: mindspace-page-sync-thumbnail — storage ENOENT 时回退读 workspace HTML
const readPageContentWithWorkspaceFallback = async ({
userId,
storageKey,
sourceSnapshotJson,
pageType,
}) => {
let snapshot = {};
try {
snapshot = parseJsonColumn(sourceSnapshotJson);
} catch {
snapshot = {};
}
const workspaceHtmlRelativePath =
pageType === 'html' ? snapshot.relative_path ?? null : null;
const workspacePublishDir = workspaceHtmlRelativePath
? await resolveWorkspacePublishDir(userId)
: null;
try {
const storagePath = await resolveReadableStoragePath(storageKey);
return await fs.readFile(storagePath, 'utf8');
} catch (error) {
const storageMissing = error?.code === 'ENOENT' || error?.code === 'storage_not_found';
if (!storageMissing || !workspacePublishDir || !workspaceHtmlRelativePath) {
throw error;
}
const workspaceHtmlPath = path.join(workspacePublishDir, workspaceHtmlRelativePath);
try {
return await fs.readFile(workspaceHtmlPath, 'utf8');
} catch (workspaceError) {
if (workspaceError?.code === 'ENOENT') {
throw error;
}
throw workspaceError;
}
}
};
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 =
source.createdAt != null && Number.isFinite(Number(source.createdAt))
? Math.floor(Number(source.createdAt))
: Date.now();
const pageWorkspaceRelativePath =
normalizeWorkspaceRelativePath(source.snapshot?.relative_path) || null;
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,
workspace_relative_path, 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,
pageWorkspaceRelativePath,
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 = ?, workspace_relative_path = ?, current_version_id = ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[
normalized.title,
normalized.summary,
normalized.pageType,
normalized.templateId,
stored.storageKey,
pageWorkspaceRelativePath,
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(() => {});
});
}
const page = await getPage(userId, pageId);
await registerPageArtifactForConversation({
registry: conversationPackageRegistry,
userId,
source,
page,
contentAssetId,
contentMimeType,
contentBytes: normalized.contentBytes,
storageKey: stored.storageKey,
versionNo: nextVersionNo,
now,
});
return page;
} 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 findPageBySourceMessage = async (userId, sessionId, messageId) => {
if (!sessionId || !messageId) 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_session_id = ? AND p.source_message_id = ? AND p.status <> 'deleted'
ORDER BY p.updated_at DESC
LIMIT 1`,
[userId, sessionId, messageId],
);
return rows[0] ? pageResponse(rows[0]) : null;
};
const findPageByRelativePath = async (userId, relativePath) => {
const normalized = normalizeWorkspaceRelativePath(relativePath);
if (!normalized) 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
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.status <> 'deleted'
AND (
p.workspace_relative_path = ?
OR (
(p.workspace_relative_path IS NULL OR p.workspace_relative_path = '')
AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
)
)
ORDER BY
CASE WHEN p.workspace_relative_path = ? THEN 0 ELSE 1 END,
CASE WHEN pr.id IS NOT NULL THEN 0 ELSE 1 END,
p.updated_at DESC,
p.id DESC
LIMIT 1`,
[userId, normalized, normalized, normalized],
);
return rows[0] ? pageResponse(rows[0]) : null;
};
const findPlazaContextByWorkspacePath = async (userId, relativePath) => {
const normalized = normalizeWorkspaceRelativePath(relativePath);
if (!normalized) return null;
const [rows] = await pool.query(
`SELECT p.id AS page_id, pr.id AS publication_id, pp.id AS post_id, pp.status AS post_status
FROM h5_page_records p
JOIN h5_page_versions pv ON pv.id = p.current_version_id
LEFT JOIN h5_publish_records pr ON pr.page_id = p.id AND pr.user_id = p.user_id AND pr.status = 'online'
LEFT JOIN plaza_posts pp ON pp.publication_id = pr.id AND pp.user_id = p.user_id AND pp.status != 'hidden'
WHERE p.user_id = ?
AND p.status <> 'deleted'
AND (
p.workspace_relative_path = ?
OR JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
)
ORDER BY
CASE pp.status WHEN 'published' THEN 0 WHEN 'pending_review' THEN 1 ELSE 2 END,
p.updated_at DESC,
p.id DESC
LIMIT 1`,
[userId, normalized, normalized],
);
const row = rows[0];
if (!row) return null;
return {
pageId: row.page_id,
publicationId: row.publication_id ?? null,
postId: row.post_id ?? null,
postStatus: row.post_status ?? 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);
}
if (filters.categoryCode) {
clauses.push(`c.category_code = ?`);
params.push(filters.categoryCode);
}
const where = clauses.join(' AND ');
const { limit, offset } = normalizeListPageFilters(filters);
const baseFrom = `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'`;
const [[countRows], [rows]] = await Promise.all([
pool.query(`SELECT COUNT(*) AS total ${baseFrom} WHERE ${where}`, params),
pool.query(
`SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
${baseFrom}
WHERE ${where}
ORDER BY p.created_at DESC, p.updated_at DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset],
),
]);
const total = asNumber(countRows[0]?.total);
return {
items: rows.map((row) => pageResponse(row, { includeContent: false })),
total,
limit,
offset,
hasMore: offset + rows.length < total,
};
};
async function getPage(userId, pageId) {
const [rows] = await pool.query(
`SELECT p.*, c.category_code, pv.version_no, av.storage_key, pv.source_snapshot_json
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 content = await readPageContentWithWorkspaceFallback({
userId,
storageKey: row.storage_key,
sourceSnapshotJson: row.source_snapshot_json,
pageType: row.page_type,
});
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 loadPageWorkspaceContext = async (userId, pageId, htmlContent = null) => {
const [rows] = await pool.query(
`SELECT pv.source_snapshot_json, p.title
FROM h5_page_records p
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`,
[pageId, userId],
);
let snapshot = {};
try {
snapshot = parseJsonColumn(rows[0]?.source_snapshot_json);
} catch {
snapshot = {};
}
const workspacePublishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null;
const workspaceHtmlRelativePath = inferWorkspaceHtmlRelativePath({
snapshotRelativePath: snapshot.relative_path,
pageTitle: rows[0]?.title,
htmlContent,
publishDir: workspacePublishDir,
});
return {
workspaceHtmlRelativePath,
workspacePublishDir,
publishKey: userId,
};
};
const rewriteHtmlDownloadLinksForPage = async (userId, pageId, html) => {
const ctx = await loadPageWorkspaceContext(userId, pageId, html);
const { html: rewritten } = await prepareHtmlDownloadLinks(pool, userId, html, {
htmlRelativePath: ctx.workspaceHtmlRelativePath,
publishDir: ctx.workspacePublishDir,
publishKey: ctx.publishKey,
publicBaseUrl: '',
preferAssetDownload: true,
});
return rewritten;
};
const finalizeHtmlPreview = async (userId, pageId, html) => {
const shell = renderHtmlPreview(html);
try {
const ctx = await loadPageWorkspaceContext(userId, pageId, html);
const { html: rewritten } = await prepareHtmlDownloadLinks(pool, userId, shell, {
htmlRelativePath: ctx.workspaceHtmlRelativePath,
publishDir: ctx.workspacePublishDir,
publishKey: ctx.publishKey,
publicBaseUrl: '',
preferAssetDownload: true,
});
return rewritten;
} catch {
return shell;
}
};
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, options = {}) => {
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');
}
if (options.sourceAssetId) {
assetKind.set(options.sourceAssetId, 'source');
}
for (const assetId of options.embeddedAssetIds ?? []) {
if (assetId) assetKind.set(assetId, 'embedded');
}
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 listPlazaPostsForPage = async (conn, userId, pageId) => {
const [rows] = await conn.query(
`SELECT pp.id, pp.title, pp.status, pp.publication_id
FROM plaza_posts pp
JOIN h5_publish_records pr ON pr.id = pp.publication_id
WHERE pr.page_id = ? AND pr.user_id = ? AND pp.status != 'hidden'
ORDER BY pp.published_at DESC, pp.updated_at DESC`,
[pageId, userId],
);
return rows.map((row) => ({
id: row.id,
title: row.title,
status: row.status,
publicationId: row.publication_id,
}));
};
const hidePlazaPostsForPage = async (conn, userId, pageId, now) => {
const [result] = await conn.query(
`UPDATE plaza_posts pp
JOIN h5_publish_records pr ON pr.id = pp.publication_id
SET pp.status = 'hidden', pp.updated_at = ?
WHERE pr.page_id = ? AND pr.user_id = ? AND pp.status != 'hidden'`,
[now, pageId, userId],
);
return asNumber(result.affectedRows);
};
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 pageDetail = await getPage(userId, pageId).catch(() => null);
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, {
sourceAssetId: page.source_asset_id,
embeddedAssetIds:
pageDetail?.contentFormat === 'html'
? extractAssetIdsFromHtml(pageDetail.content ?? '')
: [],
});
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 plazaPosts = await listPlazaPostsForPage(conn, userId, pageId);
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,
plazaPosts,
linkedAssets: assets,
linkedAssetBytes: totalBytes,
linkedAgentJobCount: asNumber(agentJobs[0]?.job_count),
preservesSourceAsset: false,
};
return { ...preview, summaryLines: buildPageDeleteSummary(preview) };
} finally {
conn.release();
}
};
const deletePage = async (userId, pageId, options = {}) => {
const removeFromPlaza = Boolean(options.removeFromPlaza);
const page = await getPage(userId, pageId);
let workspaceHtmlRelativePath = null;
try {
const [rows] = await pool.query(
`SELECT pv.source_snapshot_json
FROM h5_page_records p
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`,
[pageId, userId],
);
const snapshot = parseJsonColumn(rows[0]?.source_snapshot_json);
workspaceHtmlRelativePath = snapshot.relative_path ?? null;
} catch {
workspaceHtmlRelativePath = null;
}
const embeddedAssetIds =
page.contentFormat === 'html' ? extractAssetIdsFromHtml(page.content ?? '') : [];
const conn = await pool.getConnection();
let result;
try {
await conn.beginTransaction();
const [pages] = await conn.query(
`SELECT id, space_id, title, source_asset_id
FROM h5_page_records
WHERE id = ? AND user_id = ? AND status <> 'deleted'
LIMIT 1 FOR UPDATE`,
[pageId, userId],
);
const pageRow = pages[0];
if (!pageRow) throw pageError('页面不存在', 'page_not_found');
const now = Date.now();
const { assetIds } = await collectLinkedAssets(conn, userId, pageId, {
sourceAssetId: pageRow.source_asset_id,
embeddedAssetIds,
});
const offlinedPublicationCount = await offlineOnlinePublications(conn, userId, pageId, now);
const hiddenPlazaPostCount = removeFromPlaza
? await hidePlazaPostsForPage(conn, userId, pageId, now)
: 0;
const { deletedAssetCount, freedBytes } = await softDeleteLinkedAssets(
conn,
userId,
pageRow.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', source_asset_id = NULL
WHERE id = ? AND user_id = ?`,
[now, now, pageId, userId],
);
await conn.commit();
result = {
deleted: true,
pageId,
title: pageRow.title,
offlinedPublicationCount,
hiddenPlazaPostCount,
deletedAssetCount,
freedBytes,
clearedAgentJobCount,
};
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
const workspacePublishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null;
const purgeResult = await purgeWorkspacePageArtifacts({
publishDir: workspacePublishDir,
htmlRelativePath: workspaceHtmlRelativePath,
html: page.content ?? '',
}).catch((error) => {
console.warn('[MindSpace] workspace purge failed:', error?.message ?? error);
return { removed: [], skipped: [] };
});
return { ...result, workspaceFilesRemoved: purgeResult.removed ?? [] };
};
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 content = await readPageContentWithWorkspaceFallback({
userId,
storageKey: row.storage_key,
sourceSnapshotJson: row.source_snapshot_json,
pageType: row.page_type,
});
let snapshot = {};
try {
snapshot = parseJsonColumn(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,
createdAt: source.createdAt,
}),
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, source = {}) =>
createVersion(userId, { ...input, pageId }, { type: 'generated', ...source }),
localizePrivateResources,
redactPage,
createRedactedCopy: redactPage,
listPages,
findPageBySourceAsset,
findPageBySourceMessage,
findPageByRelativePath,
findPlazaContextByWorkspacePath,
getPage,
getDeletePreview,
deletePage,
listVersions,
rewriteHtmlDownloadLinksForPage,
renderPreview: async (userId, pageId) => {
const page = await getPage(userId, pageId);
const html =
page.contentFormat === 'html'
? await finalizeHtmlPreview(userId, pageId, 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'
? await finalizeHtmlPreview(userId, pageId, 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,
};
}
async function registerPageArtifactForConversation({
registry,
userId,
source,
page,
contentAssetId,
contentMimeType,
contentBytes,
storageKey,
versionNo,
now,
}) {
const sessionId = String(source?.sessionId ?? '').trim();
if (!registry || !sessionId || !page?.id) return null;
try {
const packageRecord = await registry.ensurePackage({
userId,
sessionId,
title: page.title,
now,
});
const artifact = await registry.recordArtifact({
id: `ca_${page.id}_${page.currentVersionId}`,
packageId: packageRecord.id,
artifactKind: 'page',
role: 'assistant',
assetId: contentAssetId,
pageId: page.id,
messageId: source.messageId ?? page.sourceMessageId ?? null,
displayName: page.title,
mimeType: contentMimeType,
sizeBytes: contentBytes,
storageKey,
canonicalUrl: `/api/mindspace/v1/pages/${encodeURIComponent(page.id)}/preview`,
sortOrder: Number.isFinite(Number(versionNo)) ? Number(versionNo) : 0,
now,
});
await registry.writeManifestForSession({ userId, sessionId });
return artifact;
} catch (error) {
console.warn(
'[MindSpace] conversation package page artifact registration failed:',
error instanceof Error ? error.message : error,
);
return null;
}
}
export const pageInternals = {
escapeHtml,
normalizePageInput,
normalizeListPageFilters,
parseJsonColumn,
renderContent,
renderPreviewHtml,
renderHtmlPreview,
previewContentSecurityPolicy,
isHtmlPage,
renderPublicationHtml,
registerPageArtifactForConversation,
redactContent,
buildPageDeleteSummary,
};
export async function inlinePrivateAssetsInHtml(pool, storageRoot, userId, html) {
const PATTERN =
/(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi;
const resolvedRoot = path.resolve(storageRoot);
const absoluteStoragePath = (key) => {
const resolved = path.resolve(resolvedRoot, key);
if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
throw Object.assign(new Error('路径越界'), { code: 'invalid_storage_path' });
}
return resolved;
};
const matches = [...String(html).matchAll(PATTERN)];
if (matches.length === 0) return { html, count: 0 };
const assetIds = [...new Set(matches.map((m) => m[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 dataUriMap = new Map();
for (const asset of assets) {
try {
const buffer = await fs.readFile(absoluteStoragePath(asset.storage_key));
dataUriMap.set(asset.id, `data:${asset.mime_type};base64,${buffer.toString('base64')}`);
} catch {
// skip unreadable assets
}
}
let count = 0;
const result = String(html).replace(PATTERN, (_match, assetId) => {
const uri = dataUriMap.get(assetId);
if (!uri) return '';
count += 1;
return uri;
});
return { html: result, count };
}
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.plazaPosts?.length > 0) {
lines.push(
`该页面在广场有 ${preview.plazaPosts.length} 个帖子;可在下方选择是否一并从广场移除`,
);
}
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`;
}