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 { resolvePageWorkspaceRelativePath, resolveWorkspaceMindSpacePublicUrl, } from './mindspace-runtime-config.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(//gi, ' ') .replace(//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, h5Root = null, env = process.env } = {}) { const workspaceRelativePath = resolvePageWorkspaceRelativePath(row); const workspacePublicUrl = resolveWorkspaceMindSpacePublicUrl({ h5Root, env, userId: row.user_id, relativePath: workspaceRelativePath, }); 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: workspacePublicUrl ?? row.pub_public_url ?? null, workspaceRelativePath: workspaceRelativePath ?? null, workspacePublicUrl, 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('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function renderContent(content) { return content .split(/\n{2,}/) .map((block) => { const trimmed = block.trim(); if (!trimmed) return ''; if (trimmed.startsWith('### ')) return `

${escapeHtml(trimmed.slice(4))}

`; if (trimmed.startsWith('## ')) return `

${escapeHtml(trimmed.slice(3))}

`; if (trimmed.startsWith('# ')) return `

${escapeHtml(trimmed.slice(2))}

`; return `

${escapeHtml(trimmed).replaceAll('\n', '
')}

`; }) .join('\n'); } function renderPreviewHtml(page) { const templateClass = TEMPLATE_IDS.has(page.templateId) ? page.templateId : 'editorial'; return ` ${escapeHtml(page.title)}
MINDSPACE DRAFT · V${page.versionNo}

${escapeHtml(page.title)}

${escapeHtml(page.summary)}
${renderContent(page.content)}
`; } function renderHtmlPreview(html) { const csp = ''; const previewShell = ''; const source = String(html); if (/]*>/i.test(source)) { return source.replace(/]*)>/i, `${csp}${previewShell}`); } if (/]*>/i.test(source)) { return source.replace(/]*)>/i, `${csp}${previewShell}`); } return `${csp}${previewShell}${source}`; } 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 toPageResponse = (row, opts = {}) => pageResponse(row, { h5Root, ...opts }); 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] ? toPageResponse(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] ? toPageResponse(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] ? toPageResponse(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) => toPageResponse(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 toPageResponse({ ...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`; }