e2ad3bf62b
- 新增 public share widget 与 workspace relative path 解析 - 增强 publications/chat-plaza 发布链路与缩略图 demo - 补充 schema、cover 检查脚本与回归测试 Co-authored-by: Cursor <cursoragent@cursor.com>
1688 lines
60 KiB
JavaScript
1688 lines
60 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,
|
||
} 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('&', '&')
|
||
.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 conversationPackageRegistry = options.conversationPackageRegistry ?? null;
|
||
|
||
const resolveWorkspacePublishDir = async (userId) => {
|
||
if (!h5Root) return null;
|
||
const [rows] = await pool.query(
|
||
`SELECT username FROM h5_users WHERE id = ? LIMIT 1`,
|
||
[userId],
|
||
);
|
||
const username = rows[0]?.username;
|
||
if (!username) return null;
|
||
return resolvePublishDir(h5Root, { id: userId });
|
||
};
|
||
|
||
const absoluteStoragePath = (storageKey) => {
|
||
const resolved = path.resolve(storageRoot, storageKey);
|
||
if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) {
|
||
throw new Error('存储路径越界');
|
||
}
|
||
return resolved;
|
||
};
|
||
|
||
const resolveStoragePathCandidates = (storageKey) => {
|
||
const normalized = String(storageKey ?? '').replace(/\\/g, '/');
|
||
const withoutMd = normalized.endsWith('.md') ? normalized.slice(0, -3) : normalized;
|
||
const match = withoutMd.match(/^users\/([^/]+)\/(assets|pages)\/([^/]+)\/(v\d+)$/);
|
||
if (!match) return [normalized];
|
||
const [, userId, scope, entityId, versionTag] = match;
|
||
return [
|
||
normalized,
|
||
`users/${userId}/${scope}/${entityId}/versions/${versionTag}.md`,
|
||
`users/${userId}/${scope}/${entityId}/versions/${versionTag}`,
|
||
].filter((candidate, index, list) => list.indexOf(candidate) === index);
|
||
};
|
||
|
||
const resolveReadableStoragePath = async (storageKey) => {
|
||
let lastError = null;
|
||
for (const candidate of resolveStoragePathCandidates(storageKey)) {
|
||
const absolutePath = absoluteStoragePath(candidate);
|
||
try {
|
||
await fs.stat(absolutePath);
|
||
return absolutePath;
|
||
} catch (error) {
|
||
if (error?.code === 'ENOENT') {
|
||
lastError = error;
|
||
continue;
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
throw lastError ?? Object.assign(new Error('存储文件不存在'), { code: 'storage_not_found' });
|
||
};
|
||
|
||
// REGRESSION GUARD: mindspace-page-sync-thumbnail — storage ENOENT 时回退读 workspace HTML
|
||
const readPageContentWithWorkspaceFallback = async ({
|
||
userId,
|
||
storageKey,
|
||
sourceSnapshotJson,
|
||
pageType,
|
||
}) => {
|
||
let snapshot = {};
|
||
try {
|
||
snapshot = parseJsonColumn(sourceSnapshotJson);
|
||
} catch {
|
||
snapshot = {};
|
||
}
|
||
const workspaceHtmlRelativePath =
|
||
pageType === 'html' ? snapshot.relative_path ?? null : null;
|
||
const workspacePublishDir = workspaceHtmlRelativePath
|
||
? await resolveWorkspacePublishDir(userId)
|
||
: null;
|
||
|
||
try {
|
||
const storagePath = await resolveReadableStoragePath(storageKey);
|
||
return await fs.readFile(storagePath, 'utf8');
|
||
} catch (error) {
|
||
const storageMissing = error?.code === 'ENOENT' || error?.code === 'storage_not_found';
|
||
if (!storageMissing || !workspacePublishDir || !workspaceHtmlRelativePath) {
|
||
throw error;
|
||
}
|
||
const workspaceHtmlPath = path.join(workspacePublishDir, workspaceHtmlRelativePath);
|
||
try {
|
||
return await fs.readFile(workspaceHtmlPath, 'utf8');
|
||
} catch (workspaceError) {
|
||
if (workspaceError?.code === 'ENOENT') {
|
||
throw error;
|
||
}
|
||
throw workspaceError;
|
||
}
|
||
}
|
||
};
|
||
|
||
const writeVersionContent = async (userId, assetId, versionId, content) => {
|
||
const storageKey = path.posix.join(
|
||
'users',
|
||
userId,
|
||
'pages',
|
||
assetId,
|
||
'versions',
|
||
`${versionId}.md`,
|
||
);
|
||
const target = absoluteStoragePath(storageKey);
|
||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||
await fs.writeFile(target, content, { flag: 'wx' });
|
||
return { storageKey, target };
|
||
};
|
||
|
||
const createVersion = async (userId, input, source = {}) => {
|
||
let pageInput = input;
|
||
if (input.pageId && input.contentFormat == null) {
|
||
const [existingPages] = await pool.query(
|
||
`SELECT page_type FROM h5_page_records WHERE id = ? AND user_id = ? AND status <> 'deleted' LIMIT 1`,
|
||
[input.pageId, userId],
|
||
);
|
||
if (existingPages[0]?.page_type === 'html') {
|
||
pageInput = { ...input, contentFormat: 'html' };
|
||
}
|
||
}
|
||
const normalized = normalizePageInput(pageInput);
|
||
const conn = await pool.getConnection();
|
||
let writtenPath;
|
||
try {
|
||
await conn.beginTransaction();
|
||
const [spaces] = await conn.query(
|
||
`SELECT id, quota_bytes, used_bytes, reserved_bytes, status
|
||
FROM h5_user_spaces WHERE user_id = ? LIMIT 1 FOR UPDATE`,
|
||
[userId],
|
||
);
|
||
const space = spaces[0];
|
||
if (!space || space.status !== 'active') {
|
||
throw pageError('用户空间不可用', 'space_unavailable');
|
||
}
|
||
const available =
|
||
asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
|
||
if (available < normalized.contentBytes) {
|
||
throw pageError('剩余空间不足', 'quota_exceeded', {
|
||
requiredBytes: normalized.contentBytes,
|
||
availableBytes: Math.max(0, available),
|
||
});
|
||
}
|
||
const [categories] = await conn.query(
|
||
`SELECT id, category_code FROM h5_space_categories
|
||
WHERE space_id = ? AND user_id = ? AND category_code = ?
|
||
LIMIT 1 FOR UPDATE`,
|
||
[space.id, userId, normalized.categoryCode],
|
||
);
|
||
const category = categories[0];
|
||
if (!category) throw pageError('目标分类不存在', 'category_not_found');
|
||
if (normalized.categoryCode !== 'draft') {
|
||
throw pageError('页面记录只能保存在页面草稿区', 'category_not_pageable');
|
||
}
|
||
|
||
let pageId = input.pageId;
|
||
let nextVersionNo = 1;
|
||
if (pageId) {
|
||
const [pages] = await conn.query(
|
||
`SELECT p.id, p.current_version_id, pv.version_no
|
||
FROM h5_page_records p
|
||
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
|
||
WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
|
||
LIMIT 1 FOR UPDATE`,
|
||
[pageId, userId],
|
||
);
|
||
const page = pages[0];
|
||
if (!page) throw pageError('页面不存在', 'page_not_found');
|
||
if (asNumber(input.expectedVersion) !== asNumber(page.version_no)) {
|
||
throw pageError('页面已被更新,请刷新后重试', 'version_conflict', {
|
||
currentVersion: asNumber(page.version_no),
|
||
});
|
||
}
|
||
nextVersionNo = asNumber(page.version_no) + 1;
|
||
} else {
|
||
pageId = idFactory();
|
||
}
|
||
|
||
const contentAssetId = idFactory();
|
||
const assetVersionId = idFactory();
|
||
const pageVersionId = idFactory();
|
||
const checksum = crypto.createHash('sha256').update(normalized.content).digest('hex');
|
||
const stored = await writeVersionContent(
|
||
userId,
|
||
contentAssetId,
|
||
assetVersionId,
|
||
normalized.content,
|
||
);
|
||
writtenPath = stored.target;
|
||
const now =
|
||
source.createdAt != null && Number.isFinite(Number(source.createdAt))
|
||
? Math.floor(Number(source.createdAt))
|
||
: Date.now();
|
||
const pageWorkspaceRelativePath =
|
||
normalizeWorkspaceRelativePath(source.snapshot?.relative_path) || null;
|
||
|
||
const contentAssetType = normalized.contentFormat === 'html' ? 'html' : 'markdown';
|
||
const contentMimeType =
|
||
normalized.contentFormat === 'html' ? 'text/html' : 'text/markdown';
|
||
const contentExtension = normalized.contentFormat === 'html' ? 'html' : 'md';
|
||
await conn.query(
|
||
`INSERT INTO h5_assets
|
||
(id, user_id, space_id, category_id, asset_type, mime_type, original_filename,
|
||
display_name, current_version_id, size_bytes, checksum, risk_level, visibility,
|
||
status, source_type, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'none',
|
||
'private', 'ready', ?, ?, ?)`,
|
||
[
|
||
contentAssetId,
|
||
userId,
|
||
space.id,
|
||
category.id,
|
||
contentAssetType,
|
||
contentMimeType,
|
||
`${pageId}-v${nextVersionNo}.${contentExtension}`,
|
||
`${normalized.title} · v${nextVersionNo}`,
|
||
assetVersionId,
|
||
normalized.contentBytes,
|
||
checksum,
|
||
source.type ?? 'generated',
|
||
now,
|
||
now,
|
||
],
|
||
);
|
||
await conn.query(
|
||
`INSERT INTO h5_asset_versions
|
||
(id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type,
|
||
created_by, change_note, scan_status, created_at)
|
||
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, 'pending', ?)`,
|
||
[
|
||
assetVersionId,
|
||
contentAssetId,
|
||
stored.storageKey,
|
||
normalized.contentBytes,
|
||
checksum,
|
||
contentMimeType,
|
||
userId,
|
||
input.changeNote ?? '保存页面草稿',
|
||
now,
|
||
],
|
||
);
|
||
|
||
if (nextVersionNo === 1) {
|
||
await conn.query(
|
||
`INSERT INTO h5_page_records
|
||
(id, user_id, space_id, category_id, source_session_id, source_message_id,
|
||
source_asset_id, title, summary, page_type, template_id, draft_content_ref,
|
||
workspace_relative_path, current_version_id, status, visibility, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 'private', ?, ?)`,
|
||
[
|
||
pageId,
|
||
userId,
|
||
space.id,
|
||
category.id,
|
||
source.sessionId ?? null,
|
||
source.messageId ?? null,
|
||
source.assetId ?? null,
|
||
normalized.title,
|
||
normalized.summary,
|
||
normalized.pageType,
|
||
normalized.templateId,
|
||
stored.storageKey,
|
||
pageWorkspaceRelativePath,
|
||
pageVersionId,
|
||
now,
|
||
now,
|
||
],
|
||
);
|
||
}
|
||
await conn.query(
|
||
`INSERT INTO h5_page_versions
|
||
(id, page_id, version_no, content_asset_id, source_snapshot_json,
|
||
created_by, change_note, immutable, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)`,
|
||
[
|
||
pageVersionId,
|
||
pageId,
|
||
nextVersionNo,
|
||
contentAssetId,
|
||
JSON.stringify(source.snapshot ?? {}),
|
||
userId,
|
||
input.changeNote ?? '保存页面草稿',
|
||
now,
|
||
],
|
||
);
|
||
if (nextVersionNo > 1) {
|
||
await conn.query(
|
||
`UPDATE h5_page_records
|
||
SET title = ?, summary = ?, page_type = ?, template_id = ?,
|
||
draft_content_ref = ?, workspace_relative_path = ?, current_version_id = ?, updated_at = ?
|
||
WHERE id = ? AND user_id = ?`,
|
||
[
|
||
normalized.title,
|
||
normalized.summary,
|
||
normalized.pageType,
|
||
normalized.templateId,
|
||
stored.storageKey,
|
||
pageWorkspaceRelativePath,
|
||
pageVersionId,
|
||
now,
|
||
pageId,
|
||
userId,
|
||
],
|
||
);
|
||
}
|
||
await conn.query(
|
||
`UPDATE h5_user_spaces SET used_bytes = used_bytes + ?, updated_at = ?
|
||
WHERE id = ? AND user_id = ?`,
|
||
[normalized.contentBytes, now, space.id, userId],
|
||
);
|
||
await conn.commit();
|
||
if (normalized.contentFormat === 'html') {
|
||
const workspaceHtmlRelativePath = source.snapshot?.relative_path ?? null;
|
||
const workspacePublishDir = workspaceHtmlRelativePath
|
||
? await resolveWorkspacePublishDir(userId)
|
||
: null;
|
||
if (workspacePublishDir && workspaceHtmlRelativePath) {
|
||
await ensureWorkspaceHtmlThumbnail(
|
||
workspacePublishDir,
|
||
workspaceHtmlRelativePath,
|
||
normalized.content,
|
||
{
|
||
title: normalized.title,
|
||
subtitle: normalized.summary || `草稿 v${nextVersionNo}`,
|
||
contentStorageKey: stored.storageKey,
|
||
},
|
||
).catch(() => {});
|
||
}
|
||
const thumbMeta = {
|
||
title: normalized.title,
|
||
subtitle: normalized.summary || `草稿 v${nextVersionNo}`,
|
||
contentStorageKey: stored.storageKey,
|
||
};
|
||
queueMicrotask(() => {
|
||
void ensurePageThumbnail({
|
||
storageRoot,
|
||
pageThumbnailStorageKey: pageThumbnailKey(userId, pageId),
|
||
html: normalized.content,
|
||
meta: thumbMeta,
|
||
workspacePublishDir,
|
||
workspaceHtmlRelativePath,
|
||
}).catch(() => {});
|
||
});
|
||
}
|
||
const page = await getPage(userId, pageId);
|
||
await registerPageArtifactForConversation({
|
||
registry: conversationPackageRegistry,
|
||
userId,
|
||
source,
|
||
page,
|
||
contentAssetId,
|
||
contentMimeType,
|
||
contentBytes: normalized.contentBytes,
|
||
storageKey: stored.storageKey,
|
||
versionNo: nextVersionNo,
|
||
now,
|
||
});
|
||
return page;
|
||
} catch (error) {
|
||
await conn.rollback();
|
||
if (writtenPath) await fs.rm(writtenPath, { force: true }).catch(() => {});
|
||
throw error;
|
||
} finally {
|
||
conn.release();
|
||
}
|
||
};
|
||
|
||
const findPageBySourceAsset = async (userId, assetId) => {
|
||
if (!assetId) return null;
|
||
const [rows] = await pool.query(
|
||
`SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
|
||
FROM h5_page_records p
|
||
JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
|
||
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
|
||
LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'
|
||
WHERE p.user_id = ? AND p.source_asset_id = ? AND p.status <> 'deleted'
|
||
ORDER BY p.updated_at DESC
|
||
LIMIT 1`,
|
||
[userId, assetId],
|
||
);
|
||
return rows[0] ? pageResponse(rows[0]) : null;
|
||
};
|
||
|
||
const findPageBySourceMessage = async (userId, sessionId, messageId) => {
|
||
if (!sessionId || !messageId) return null;
|
||
const [rows] = await pool.query(
|
||
`SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
|
||
FROM h5_page_records p
|
||
JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
|
||
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
|
||
LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'
|
||
WHERE p.user_id = ? AND p.source_session_id = ? AND p.source_message_id = ? AND p.status <> 'deleted'
|
||
ORDER BY p.updated_at DESC
|
||
LIMIT 1`,
|
||
[userId, sessionId, messageId],
|
||
);
|
||
return rows[0] ? pageResponse(rows[0]) : null;
|
||
};
|
||
|
||
const findPageByRelativePath = async (userId, relativePath) => {
|
||
const normalized = normalizeWorkspaceRelativePath(relativePath);
|
||
if (!normalized) return null;
|
||
const [rows] = await pool.query(
|
||
`SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
|
||
FROM h5_page_records p
|
||
JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
|
||
JOIN h5_page_versions pv ON pv.id = p.current_version_id
|
||
LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'
|
||
WHERE p.user_id = ?
|
||
AND p.status <> 'deleted'
|
||
AND (
|
||
p.workspace_relative_path = ?
|
||
OR (
|
||
(p.workspace_relative_path IS NULL OR p.workspace_relative_path = '')
|
||
AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
|
||
)
|
||
)
|
||
ORDER BY 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,
|
||
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,
|
||
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`;
|
||
}
|