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