Files
tkmind_go/ui/h5/mindspace-pages.mjs
john 4e21ca937a
Deploy Documentation / deploy (push) Has been cancelled
Canary / Prepare Version (push) Has been cancelled
Canary / build-cli (push) Has been cancelled
Canary / Upload Install Script (push) Has been cancelled
Canary / bundle-desktop (push) Has been cancelled
Canary / bundle-desktop-intel (push) Has been cancelled
Canary / bundle-desktop-linux (push) Has been cancelled
Canary / bundle-desktop-windows (push) Has been cancelled
Canary / bundle-desktop-windows-cuda (push) Has been cancelled
Canary / Release (push) Has been cancelled
Unused Dependencies / machete (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / Check Rust Code Format (push) Has been cancelled
CI / Build and Test Rust Project (push) Has been cancelled
CI / Build Rust Project on Windows (push) Has been cancelled
CI / Check MSRV (push) Has been cancelled
CI / Lint Rust Code (push) Has been cancelled
CI / Check Generated Schemas are Up-to-Date (push) Has been cancelled
CI / Test and Lint Electron Desktop App (push) Has been cancelled
CI / H5 Plaza Tests and Build (push) Has been cancelled
Live Provider Tests / check-fork (push) Has been cancelled
Live Provider Tests / changes (push) Has been cancelled
Live Provider Tests / Build Binary (push) Has been cancelled
Live Provider Tests / Smoke Tests (push) Has been cancelled
Live Provider Tests / Smoke Tests (Code Execution) (push) Has been cancelled
Live Provider Tests / Compaction Tests (push) Has been cancelled
Live Provider Tests / goose server HTTP integration tests (push) Has been cancelled
Publish Ask AI Bot Docker Image / docker (push) Has been cancelled
Publish Docker Image / docker (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Add TKMind platform extensions, H5/MindSpace stack, and deployment tooling.
Fork goose with custom MCP widgets, platform extensions (aider, git, web, search),
MindSpace H5 backend/frontend, Plaza/Ops UIs, and deploy scripts for tkmind.cn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 21:30:20 +08:00

921 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { redactContent, scanContent } from './mindspace-content-scan.mjs';
import { ensurePageThumbnail, pageThumbnailKey } from './mindspace-thumbnails.mjs';
import { resolvePublishDir } from './user-publish.mjs';
import { ensureWorkspaceHtmlThumbnail } from './mindspace-workspace-thumbnails.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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function renderContent(content) {
return content
.split(/\n{2,}/)
.map((block) => {
const trimmed = block.trim();
if (!trimmed) return '';
if (trimmed.startsWith('### ')) return `<h3>${escapeHtml(trimmed.slice(4))}</h3>`;
if (trimmed.startsWith('## ')) return `<h2>${escapeHtml(trimmed.slice(3))}</h2>`;
if (trimmed.startsWith('# ')) return `<h1>${escapeHtml(trimmed.slice(2))}</h1>`;
return `<p>${escapeHtml(trimmed).replaceAll('\n', '<br>')}</p>`;
})
.join('\n');
}
function renderPreviewHtml(page) {
const templateClass = TEMPLATE_IDS.has(page.templateId) ? page.templateId : 'editorial';
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'">
<title>${escapeHtml(page.title)}</title>
<style>
:root { color: #18211d; background: #f5f0e5; font-family: Georgia, "Times New Roman", serif; }
* { box-sizing: border-box; }
body { margin: 0; padding: clamp(24px, 7vw, 88px); background: radial-gradient(circle at 90% 0, #d8e8df, transparent 34rem), #f5f0e5; }
article { width: min(820px, 100%); margin: 0 auto; padding: clamp(28px, 6vw, 72px); border: 1px solid #d7d0c1; border-radius: 28px; background: rgba(255,253,247,.94); box-shadow: 0 24px 70px rgba(45,53,47,.12); }
.label { color: #9b6518; font: 800 12px/1.4 ui-sans-serif, sans-serif; letter-spacing: .18em; }
h1 { margin: 12px 0 18px; font-size: clamp(40px, 8vw, 76px); line-height: 1; letter-spacing: -.045em; }
.summary { color: #5c655f; font: 18px/1.7 ui-sans-serif, sans-serif; }
.content { margin-top: 42px; font-size: 20px; line-height: 1.85; }
.content h1, .content h2, .content h3 { margin-top: 1.4em; font-size: 1.55em; letter-spacing: -.02em; }
.content p { margin: 0 0 1.2em; }
.report article { border-top: 8px solid #2f6f57; }
.knowledge-card article { max-width: 680px; background: linear-gradient(145deg, #fffaf0, #e4eddf); }
@media (max-width: 560px) { body { padding: 12px; } article { border-radius: 20px; } .content { font-size: 17px; } }
</style>
</head>
<body class="${templateClass}">
<article>
<div class="label">MINDSPACE DRAFT · V${page.versionNo}</div>
<h1>${escapeHtml(page.title)}</h1>
<div class="summary">${escapeHtml(page.summary)}</div>
<div class="content">${renderContent(page.content)}</div>
</article>
</body>
</html>`;
}
function renderHtmlPreview(html) {
const csp =
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\' https:; img-src data: https:; font-src https: data:; base-uri \'none\'; form-action \'none\'; script-src \'none\'">';
const previewShell =
'<style id="mindspace-preview-shell">html,body{margin:0;padding:0;background:#f5f0e5}body{min-height:100%}</style>';
const source = String(html);
if (/<head[^>]*>/i.test(source)) {
return source.replace(/<head([^>]*)>/i, `<head$1>${csp}${previewShell}`);
}
if (/<html[^>]*>/i.test(source)) {
return source.replace(/<html([^>]*)>/i, `<html$1><head>${csp}${previewShell}</head>`);
}
return `<!doctype html><html lang="zh-CN"><head>${csp}${previewShell}</head><body>${source}</body></html>`;
}
export function previewContentSecurityPolicy(contentFormat) {
if (contentFormat === 'html') {
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src data: https:; font-src https: data:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; script-src 'none'";
}
return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'";
}
function isHtmlPage(page) {
return page.pageType === 'html' || page.page_type === 'html' || page.contentFormat === 'html';
}
function renderPublicationHtml(page) {
if (isHtmlPage(page)) {
return renderHtmlPreview(page.content);
}
return renderPreviewHtml({
title: page.title,
summary: page.summary ?? '',
templateId: page.template_id,
versionNo: page.version_no,
content: page.content,
}).replace(
`MINDSPACE DRAFT · V${page.version_no}`,
`MINDSPACE · V${page.version_no}`,
);
}
export function createPageService(pool, options = {}) {
const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace'));
const h5Root = options.h5Root ? path.resolve(options.h5Root) : null;
const idFactory = options.idFactory ?? (() => crypto.randomUUID());
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, { username });
};
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) {
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(() => {});
});
}
return getPage(userId, pageId);
} catch (error) {
await conn.rollback();
if (writtenPath) await fs.rm(writtenPath, { force: true }).catch(() => {});
throw error;
} finally {
conn.release();
}
};
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 createRedactedCopy = async (userId, pageId, input = {}) => {
const page = await getPage(userId, pageId);
if (input.pageVersionId && input.pageVersionId !== page.currentVersionId) {
throw pageError('只能基于当前版本生成脱敏副本', 'version_conflict', {
currentVersion: page.currentVersionId,
});
}
const contentFormat = page.contentFormat === 'html' ? 'html' : 'markdown';
const scanFormat = contentFormat === 'html' ? 'html' : 'text';
const titleRedaction = redactContent(page.title, { format: 'text' });
const summaryRedaction = redactContent(page.summary ?? '', { format: 'text' });
const contentRedaction = redactContent(page.content ?? '', { format: scanFormat });
const created = await createVersion(
userId,
{
title: titleRedaction.changed
? titleRedaction.content
: `${page.title.replace(/\s*\(脱敏副本\)\s*$/u, '')}(脱敏副本)`,
summary: summaryRedaction.content.trim() || page.summary || '已生成脱敏副本',
content: contentRedaction.content,
templateId: page.templateId,
pageType: page.pageType,
contentFormat,
},
{
type: 'generated',
assetId: page.sourceAssetId ?? null,
snapshot: {
source_page_id: page.id,
source_version_id: page.currentVersionId,
desensitized: true,
},
},
);
return {
page: created,
originalScan: scanContent(`${page.title}\n${page.summary ?? ''}\n${page.content ?? ''}`, {
format: scanFormat,
}),
redactedScan: scanContent(
`${created.title}\n${created.summary ?? ''}\n${created.content ?? ''}`,
{ format: scanFormat },
),
redactionsApplied:
titleRedaction.redactionCount +
summaryRedaction.redactionCount +
contentRedaction.redactionCount,
};
};
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();
}
};
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' }),
createRedactedCopy,
listPages,
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 };
},
renderThumbnail: 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 ensurePageThumbnail({
storageRoot,
pageThumbnailStorageKey: pageThumbnailKey(userId, pageId),
html: content,
meta: {
title: row.title,
subtitle: row.summary || `草稿 v${asNumber(row.version_no)}`,
contentStorageKey: row.storage_key,
},
workspacePublishDir,
workspaceHtmlRelativePath,
});
},
};
}
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`;
}