Files

1688 lines
60 KiB
JavaScript
Raw Permalink Normal View History

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 { 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 });
}
/** Normalize workspace HTML paths so `demo.html` and `public/demo.html` match. */
export function normalizeWorkspaceRelativePath(relativePath) {
const parts = String(relativePath ?? '')
.replace(/^\/+/, '')
.split('/')
.filter((part) => part && part !== '.' && part !== '..');
if (parts.length === 0) return '';
if (parts[0].toLowerCase() === 'public') return parts.join('/');
if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`;
return parts.join('/');
}
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: 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());
2026-07-02 21:22:43 +08:00
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;
2026-06-29 22:20:04 +08:00
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(() => {});
});
}
2026-07-02 21:22:43 +08:00
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;
};
2026-06-27 21:57:21 +08:00
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 p.updated_at DESC, p.id DESC
LIMIT 1`,
[userId, 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,
2026-06-29 22:20:04 +08:00
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) =>
createVersion(userId, { ...input, pageId }, { type: 'generated' }),
localizePrivateResources,
redactPage,
createRedactedCopy: redactPage,
listPages,
findPageBySourceAsset,
2026-06-27 21:57:21 +08:00
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,
};
}
2026-07-02 21:22:43 +08:00
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,
2026-07-02 22:01:35 +08:00
canonicalUrl: `/api/mindspace/v1/pages/${encodeURIComponent(page.id)}/preview`,
2026-07-02 21:22:43 +08:00
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,
2026-07-02 21:22:43 +08:00
registerPageArtifactForConversation,
redactContent,
buildPageDeleteSummary,
};
2026-06-27 21:57:21 +08:00
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`;
}