2026-06-15 15:04:43 -07:00
|
|
|
|
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';
|
2026-06-30 09:30:51 +08:00
|
|
|
|
import { prepareHtmlDownloadLinks, inferWorkspaceHtmlRelativePath } from './mindspace-html-download-links.mjs';
|
|
|
|
|
|
import { prepareHtmlPageBrandMarkers } from './mindspace-page-tag.mjs';
|
2026-06-30 01:22:38 +08:00
|
|
|
|
import { purgeWorkspacePageArtifacts, extractAssetIdsFromHtml } from './mindspace-page-purge.mjs';
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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']);
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const PRIVATE_ASSET_URL_PATTERN =
|
|
|
|
|
|
/(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi;
|
2026-06-15 15:04:43 -07:00
|
|
|
|
|
|
|
|
|
|
function asNumber(value) {
|
|
|
|
|
|
return Number(value ?? 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 01:39:47 +08:00
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
function pageError(message, code, details) {
|
|
|
|
|
|
return Object.assign(new Error(message), { code, details });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 09:30:51 +08:00
|
|
|
|
/** 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('/');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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, '标题');
|
2026-06-30 09:30:51 +08:00
|
|
|
|
let content = normalizeText(input.content, MAX_CONTENT_BYTES, '页面内容');
|
|
|
|
|
|
const contentFormat = input.contentFormat === 'html' ? 'html' : 'markdown';
|
|
|
|
|
|
if (contentFormat === 'html') {
|
|
|
|
|
|
content = prepareHtmlPageBrandMarkers(content);
|
|
|
|
|
|
}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 01:22:38 +08:00
|
|
|
|
function pageResponse(row, { includeContent = true } = {}) {
|
|
|
|
|
|
const response = {
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
publicationUrl: row.pub_public_url ?? null,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
currentVersionId: row.current_version_id,
|
|
|
|
|
|
versionNo: asNumber(row.version_no),
|
|
|
|
|
|
createdAt: asNumber(row.created_at),
|
|
|
|
|
|
updatedAt: asNumber(row.updated_at),
|
|
|
|
|
|
};
|
2026-06-30 01:22:38 +08:00
|
|
|
|
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 };
|
2026-06-15 15:04:43 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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());
|
2026-07-02 21:22:43 +08:00
|
|
|
|
const conversationPackageRegistry = options.conversationPackageRegistry ?? null;
|
2026-06-15 15:04:43 -07:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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' });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-05 07:57:04 +08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
const writeVersionContent = async (userId, assetId, versionId, content) => {
|
|
|
|
|
|
const storageKey = path.posix.join(
|
|
|
|
|
|
'users',
|
|
|
|
|
|
userId,
|
|
|
|
|
|
'pages',
|
|
|
|
|
|
assetId,
|
|
|
|
|
|
'versions',
|
|
|
|
|
|
`${versionId}.md`,
|
|
|
|
|
|
);
|
|
|
|
|
|
const target = absoluteStoragePath(storageKey);
|
|
|
|
|
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
|
|
|
|
await fs.writeFile(target, content, { flag: 'wx' });
|
|
|
|
|
|
return { storageKey, target };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const createVersion = async (userId, input, source = {}) => {
|
|
|
|
|
|
let pageInput = input;
|
|
|
|
|
|
if (input.pageId && input.contentFormat == null) {
|
|
|
|
|
|
const [existingPages] = await pool.query(
|
|
|
|
|
|
`SELECT page_type FROM h5_page_records WHERE id = ? AND user_id = ? AND status <> 'deleted' LIMIT 1`,
|
|
|
|
|
|
[input.pageId, userId],
|
|
|
|
|
|
);
|
|
|
|
|
|
if (existingPages[0]?.page_type === 'html') {
|
|
|
|
|
|
pageInput = { ...input, contentFormat: 'html' };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const normalized = normalizePageInput(pageInput);
|
|
|
|
|
|
const conn = await pool.getConnection();
|
|
|
|
|
|
let writtenPath;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await conn.beginTransaction();
|
|
|
|
|
|
const [spaces] = await conn.query(
|
|
|
|
|
|
`SELECT id, quota_bytes, used_bytes, reserved_bytes, status
|
|
|
|
|
|
FROM h5_user_spaces WHERE user_id = ? LIMIT 1 FOR UPDATE`,
|
|
|
|
|
|
[userId],
|
|
|
|
|
|
);
|
|
|
|
|
|
const space = spaces[0];
|
|
|
|
|
|
if (!space || space.status !== 'active') {
|
|
|
|
|
|
throw pageError('用户空间不可用', 'space_unavailable');
|
|
|
|
|
|
}
|
|
|
|
|
|
const available =
|
|
|
|
|
|
asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
|
|
|
|
|
|
if (available < normalized.contentBytes) {
|
|
|
|
|
|
throw pageError('剩余空间不足', 'quota_exceeded', {
|
|
|
|
|
|
requiredBytes: normalized.contentBytes,
|
|
|
|
|
|
availableBytes: Math.max(0, available),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
const [categories] = await conn.query(
|
|
|
|
|
|
`SELECT id, category_code FROM h5_space_categories
|
|
|
|
|
|
WHERE space_id = ? AND user_id = ? AND category_code = ?
|
|
|
|
|
|
LIMIT 1 FOR UPDATE`,
|
|
|
|
|
|
[space.id, userId, normalized.categoryCode],
|
|
|
|
|
|
);
|
|
|
|
|
|
const category = categories[0];
|
|
|
|
|
|
if (!category) throw pageError('目标分类不存在', 'category_not_found');
|
|
|
|
|
|
if (normalized.categoryCode !== 'draft') {
|
|
|
|
|
|
throw pageError('页面记录只能保存在页面草稿区', 'category_not_pageable');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let pageId = input.pageId;
|
|
|
|
|
|
let nextVersionNo = 1;
|
|
|
|
|
|
if (pageId) {
|
|
|
|
|
|
const [pages] = await conn.query(
|
|
|
|
|
|
`SELECT p.id, p.current_version_id, pv.version_no
|
|
|
|
|
|
FROM h5_page_records p
|
|
|
|
|
|
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
|
|
|
|
|
|
WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
|
|
|
|
|
|
LIMIT 1 FOR UPDATE`,
|
|
|
|
|
|
[pageId, userId],
|
|
|
|
|
|
);
|
|
|
|
|
|
const page = pages[0];
|
|
|
|
|
|
if (!page) throw pageError('页面不存在', 'page_not_found');
|
|
|
|
|
|
if (asNumber(input.expectedVersion) !== asNumber(page.version_no)) {
|
|
|
|
|
|
throw pageError('页面已被更新,请刷新后重试', 'version_conflict', {
|
|
|
|
|
|
currentVersion: asNumber(page.version_no),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
nextVersionNo = asNumber(page.version_no) + 1;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
pageId = idFactory();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const contentAssetId = idFactory();
|
|
|
|
|
|
const assetVersionId = idFactory();
|
|
|
|
|
|
const pageVersionId = idFactory();
|
|
|
|
|
|
const checksum = crypto.createHash('sha256').update(normalized.content).digest('hex');
|
|
|
|
|
|
const stored = await writeVersionContent(
|
|
|
|
|
|
userId,
|
|
|
|
|
|
contentAssetId,
|
|
|
|
|
|
assetVersionId,
|
|
|
|
|
|
normalized.content,
|
|
|
|
|
|
);
|
|
|
|
|
|
writtenPath = stored.target;
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const now =
|
|
|
|
|
|
source.createdAt != null && Number.isFinite(Number(source.createdAt))
|
|
|
|
|
|
? Math.floor(Number(source.createdAt))
|
|
|
|
|
|
: Date.now();
|
2026-07-05 23:14:41 +08:00
|
|
|
|
const pageWorkspaceRelativePath =
|
|
|
|
|
|
normalizeWorkspaceRelativePath(source.snapshot?.relative_path) || null;
|
2026-06-15 15:04:43 -07:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-07-05 23:14:41 +08:00
|
|
|
|
workspace_relative_path, current_version_id, status, visibility, created_at, updated_at)
|
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 'private', ?, ?)`,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
[
|
|
|
|
|
|
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,
|
2026-07-05 23:14:41 +08:00
|
|
|
|
pageWorkspaceRelativePath,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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 = ?,
|
2026-07-05 23:14:41 +08:00
|
|
|
|
draft_content_ref = ?, workspace_relative_path = ?, current_version_id = ?, updated_at = ?
|
2026-06-15 15:04:43 -07:00
|
|
|
|
WHERE id = ? AND user_id = ?`,
|
|
|
|
|
|
[
|
|
|
|
|
|
normalized.title,
|
|
|
|
|
|
normalized.summary,
|
|
|
|
|
|
normalized.pageType,
|
|
|
|
|
|
normalized.templateId,
|
|
|
|
|
|
stored.storageKey,
|
2026-07-05 23:14:41 +08:00
|
|
|
|
pageWorkspaceRelativePath,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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) {
|
2026-06-17 16:39:39 -07:00
|
|
|
|
await ensureWorkspaceHtmlThumbnail(
|
2026-06-15 15:04:43 -07:00
|
|
|
|
workspacePublishDir,
|
|
|
|
|
|
workspaceHtmlRelativePath,
|
|
|
|
|
|
normalized.content,
|
|
|
|
|
|
{
|
|
|
|
|
|
title: normalized.title,
|
|
|
|
|
|
subtitle: normalized.summary || `草稿 v${nextVersionNo}`,
|
|
|
|
|
|
contentStorageKey: stored.storageKey,
|
|
|
|
|
|
},
|
|
|
|
|
|
).catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
const thumbMeta = {
|
|
|
|
|
|
title: normalized.title,
|
|
|
|
|
|
subtitle: normalized.summary || `草稿 v${nextVersionNo}`,
|
|
|
|
|
|
contentStorageKey: stored.storageKey,
|
|
|
|
|
|
};
|
|
|
|
|
|
queueMicrotask(() => {
|
|
|
|
|
|
void ensurePageThumbnail({
|
|
|
|
|
|
storageRoot,
|
|
|
|
|
|
pageThumbnailStorageKey: pageThumbnailKey(userId, pageId),
|
|
|
|
|
|
html: normalized.content,
|
|
|
|
|
|
meta: thumbMeta,
|
|
|
|
|
|
workspacePublishDir,
|
|
|
|
|
|
workspaceHtmlRelativePath,
|
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-07-02 21:22:43 +08:00
|
|
|
|
const page = await getPage(userId, pageId);
|
|
|
|
|
|
await registerPageArtifactForConversation({
|
|
|
|
|
|
registry: conversationPackageRegistry,
|
|
|
|
|
|
userId,
|
|
|
|
|
|
source,
|
|
|
|
|
|
page,
|
|
|
|
|
|
contentAssetId,
|
|
|
|
|
|
contentMimeType,
|
|
|
|
|
|
contentBytes: normalized.contentBytes,
|
|
|
|
|
|
storageKey: stored.storageKey,
|
|
|
|
|
|
versionNo: nextVersionNo,
|
|
|
|
|
|
now,
|
|
|
|
|
|
});
|
|
|
|
|
|
return page;
|
2026-06-15 15:04:43 -07:00
|
|
|
|
} 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;
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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
|
2026-06-15 15:04:43 -07:00
|
|
|
|
FROM h5_page_records p
|
|
|
|
|
|
JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
|
|
|
|
|
|
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
|
|
|
|
|
|
LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'
|
|
|
|
|
|
WHERE p.user_id = ? AND p.source_asset_id = ? AND p.status <> 'deleted'
|
|
|
|
|
|
ORDER BY p.updated_at DESC
|
|
|
|
|
|
LIMIT 1`,
|
|
|
|
|
|
[userId, assetId],
|
|
|
|
|
|
);
|
|
|
|
|
|
return rows[0] ? pageResponse(rows[0]) : null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-27 21:57:21 +08:00
|
|
|
|
const findPageBySourceMessage = async (userId, sessionId, messageId) => {
|
|
|
|
|
|
if (!sessionId || !messageId) return null;
|
|
|
|
|
|
const [rows] = await pool.query(
|
|
|
|
|
|
`SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
|
|
|
|
|
|
FROM h5_page_records p
|
|
|
|
|
|
JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
|
|
|
|
|
|
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
|
|
|
|
|
|
LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'
|
|
|
|
|
|
WHERE p.user_id = ? AND p.source_session_id = ? AND p.source_message_id = ? AND p.status <> 'deleted'
|
|
|
|
|
|
ORDER BY p.updated_at DESC
|
|
|
|
|
|
LIMIT 1`,
|
|
|
|
|
|
[userId, sessionId, messageId],
|
|
|
|
|
|
);
|
|
|
|
|
|
return rows[0] ? pageResponse(rows[0]) : null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-30 09:30:51 +08:00
|
|
|
|
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'
|
2026-07-05 23:14:41 +08:00
|
|
|
|
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')) = ?
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-06-30 09:30:51 +08:00
|
|
|
|
ORDER BY p.updated_at DESC, p.id DESC
|
|
|
|
|
|
LIMIT 1`,
|
2026-07-05 23:14:41 +08:00
|
|
|
|
[userId, normalized, normalized],
|
2026-06-30 09:30:51 +08:00
|
|
|
|
);
|
|
|
|
|
|
return rows[0] ? pageResponse(rows[0]) : null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-05 23:14:41 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-06-30 01:22:38 +08:00
|
|
|
|
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
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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
|
2026-06-30 01:22:38 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
2026-06-15 15:04:43 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
async function getPage(userId, pageId) {
|
|
|
|
|
|
const [rows] = await pool.query(
|
2026-07-05 07:57:04 +08:00
|
|
|
|
`SELECT p.*, c.category_code, pv.version_no, av.storage_key, pv.source_snapshot_json
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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');
|
2026-07-05 07:57:04 +08:00
|
|
|
|
const content = await readPageContentWithWorkspaceFallback({
|
|
|
|
|
|
userId,
|
|
|
|
|
|
storageKey: row.storage_key,
|
|
|
|
|
|
sourceSnapshotJson: row.source_snapshot_json,
|
|
|
|
|
|
pageType: row.page_type,
|
|
|
|
|
|
});
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-30 09:30:51 +08:00
|
|
|
|
const loadPageWorkspaceContext = async (userId, pageId, htmlContent = null) => {
|
2026-06-30 01:22:38 +08:00
|
|
|
|
const [rows] = await pool.query(
|
2026-06-30 09:30:51 +08:00
|
|
|
|
`SELECT pv.source_snapshot_json, p.title
|
2026-06-30 01:22:38 +08:00
|
|
|
|
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 {
|
2026-06-30 01:39:47 +08:00
|
|
|
|
snapshot = parseJsonColumn(rows[0]?.source_snapshot_json);
|
2026-06-30 01:22:38 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
snapshot = {};
|
|
|
|
|
|
}
|
|
|
|
|
|
const workspacePublishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null;
|
2026-06-30 09:30:51 +08:00
|
|
|
|
const workspaceHtmlRelativePath = inferWorkspaceHtmlRelativePath({
|
|
|
|
|
|
snapshotRelativePath: snapshot.relative_path,
|
|
|
|
|
|
pageTitle: rows[0]?.title,
|
|
|
|
|
|
htmlContent,
|
|
|
|
|
|
publishDir: workspacePublishDir,
|
|
|
|
|
|
});
|
2026-06-30 01:22:38 +08:00
|
|
|
|
return {
|
|
|
|
|
|
workspaceHtmlRelativePath,
|
|
|
|
|
|
workspacePublishDir,
|
|
|
|
|
|
publishKey: userId,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-30 09:30:51 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-30 01:22:38 +08:00
|
|
|
|
const finalizeHtmlPreview = async (userId, pageId, html) => {
|
|
|
|
|
|
const shell = renderHtmlPreview(html);
|
|
|
|
|
|
try {
|
2026-06-30 09:30:51 +08:00
|
|
|
|
const ctx = await loadPageWorkspaceContext(userId, pageId, html);
|
2026-06-30 01:22:38 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-30 01:22:38 +08:00
|
|
|
|
const collectLinkedAssets = async (conn, userId, pageId, options = {}) => {
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
2026-06-30 01:22:38 +08:00
|
|
|
|
if (options.sourceAssetId) {
|
|
|
|
|
|
assetKind.set(options.sourceAssetId, 'source');
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const assetId of options.embeddedAssetIds ?? []) {
|
|
|
|
|
|
if (assetId) assetKind.set(assetId, 'embedded');
|
|
|
|
|
|
}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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),
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-30 01:22:38 +08:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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');
|
2026-06-30 01:22:38 +08:00
|
|
|
|
const pageDetail = await getPage(userId, pageId).catch(() => null);
|
2026-06-15 15:04:43 -07:00
|
|
|
|
|
|
|
|
|
|
const conn = await pool.getConnection();
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [versionRows] = await conn.query(
|
|
|
|
|
|
`SELECT COUNT(*) AS version_count FROM h5_page_versions WHERE page_id = ?`,
|
|
|
|
|
|
[pageId],
|
|
|
|
|
|
);
|
2026-06-30 01:22:38 +08:00
|
|
|
|
const { assets, totalBytes } = await collectLinkedAssets(conn, userId, pageId, {
|
|
|
|
|
|
sourceAssetId: page.source_asset_id,
|
|
|
|
|
|
embeddedAssetIds:
|
|
|
|
|
|
pageDetail?.contentFormat === 'html'
|
|
|
|
|
|
? extractAssetIdsFromHtml(pageDetail.content ?? '')
|
|
|
|
|
|
: [],
|
|
|
|
|
|
});
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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;
|
2026-06-30 01:22:38 +08:00
|
|
|
|
const plazaPosts = await listPlazaPostsForPage(conn, userId, pageId);
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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,
|
2026-06-30 01:22:38 +08:00
|
|
|
|
plazaPosts,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
linkedAssets: assets,
|
|
|
|
|
|
linkedAssetBytes: totalBytes,
|
|
|
|
|
|
linkedAgentJobCount: asNumber(agentJobs[0]?.job_count),
|
2026-06-30 01:22:38 +08:00
|
|
|
|
preservesSourceAsset: false,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
};
|
|
|
|
|
|
return { ...preview, summaryLines: buildPageDeleteSummary(preview) };
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
conn.release();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-30 01:22:38 +08:00
|
|
|
|
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],
|
|
|
|
|
|
);
|
2026-06-30 01:39:47 +08:00
|
|
|
|
const snapshot = parseJsonColumn(rows[0]?.source_snapshot_json);
|
2026-06-30 01:22:38 +08:00
|
|
|
|
workspaceHtmlRelativePath = snapshot.relative_path ?? null;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
workspaceHtmlRelativePath = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const embeddedAssetIds =
|
|
|
|
|
|
page.contentFormat === 'html' ? extractAssetIdsFromHtml(page.content ?? '') : [];
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
const conn = await pool.getConnection();
|
2026-06-30 01:22:38 +08:00
|
|
|
|
let result;
|
2026-06-15 15:04:43 -07:00
|
|
|
|
try {
|
|
|
|
|
|
await conn.beginTransaction();
|
|
|
|
|
|
const [pages] = await conn.query(
|
2026-06-30 01:22:38 +08:00
|
|
|
|
`SELECT id, space_id, title, source_asset_id
|
2026-06-15 15:04:43 -07:00
|
|
|
|
FROM h5_page_records
|
|
|
|
|
|
WHERE id = ? AND user_id = ? AND status <> 'deleted'
|
|
|
|
|
|
LIMIT 1 FOR UPDATE`,
|
|
|
|
|
|
[pageId, userId],
|
|
|
|
|
|
);
|
2026-06-30 01:22:38 +08:00
|
|
|
|
const pageRow = pages[0];
|
|
|
|
|
|
if (!pageRow) throw pageError('页面不存在', 'page_not_found');
|
2026-06-15 15:04:43 -07:00
|
|
|
|
|
|
|
|
|
|
const now = Date.now();
|
2026-06-30 01:22:38 +08:00
|
|
|
|
const { assetIds } = await collectLinkedAssets(conn, userId, pageId, {
|
|
|
|
|
|
sourceAssetId: pageRow.source_asset_id,
|
|
|
|
|
|
embeddedAssetIds,
|
|
|
|
|
|
});
|
2026-06-15 15:04:43 -07:00
|
|
|
|
const offlinedPublicationCount = await offlineOnlinePublications(conn, userId, pageId, now);
|
2026-06-30 01:22:38 +08:00
|
|
|
|
const hiddenPlazaPostCount = removeFromPlaza
|
|
|
|
|
|
? await hidePlazaPostsForPage(conn, userId, pageId, now)
|
|
|
|
|
|
: 0;
|
2026-06-15 15:04:43 -07:00
|
|
|
|
const { deletedAssetCount, freedBytes } = await softDeleteLinkedAssets(
|
|
|
|
|
|
conn,
|
|
|
|
|
|
userId,
|
2026-06-30 01:22:38 +08:00
|
|
|
|
pageRow.space_id,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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 = ?,
|
2026-06-30 01:22:38 +08:00
|
|
|
|
current_publish_id = NULL, visibility = 'private', source_asset_id = NULL
|
2026-06-15 15:04:43 -07:00
|
|
|
|
WHERE id = ? AND user_id = ?`,
|
|
|
|
|
|
[now, now, pageId, userId],
|
|
|
|
|
|
);
|
|
|
|
|
|
await conn.commit();
|
2026-06-30 01:22:38 +08:00
|
|
|
|
result = {
|
2026-06-15 15:04:43 -07:00
|
|
|
|
deleted: true,
|
|
|
|
|
|
pageId,
|
2026-06-30 01:22:38 +08:00
|
|
|
|
title: pageRow.title,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
offlinedPublicationCount,
|
2026-06-30 01:22:38 +08:00
|
|
|
|
hiddenPlazaPostCount,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
deletedAssetCount,
|
|
|
|
|
|
freedBytes,
|
|
|
|
|
|
clearedAgentJobCount,
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
await conn.rollback();
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
conn.release();
|
|
|
|
|
|
}
|
2026-06-30 01:22:38 +08:00
|
|
|
|
|
|
|
|
|
|
const workspacePublishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null;
|
|
|
|
|
|
const purgeResult = await purgeWorkspacePageArtifacts({
|
|
|
|
|
|
publishDir: workspacePublishDir,
|
|
|
|
|
|
htmlRelativePath: workspaceHtmlRelativePath,
|
|
|
|
|
|
html: page.content ?? '',
|
2026-06-30 01:39:47 +08:00
|
|
|
|
}).catch((error) => {
|
|
|
|
|
|
console.warn('[MindSpace] workspace purge failed:', error?.message ?? error);
|
|
|
|
|
|
return { removed: [], skipped: [] };
|
|
|
|
|
|
});
|
2026-06-30 01:22:38 +08:00
|
|
|
|
|
|
|
|
|
|
return { ...result, workspaceFilesRemoved: purgeResult.removed ?? [] };
|
2026-06-15 15:04:43 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
}
|
2026-07-05 07:57:04 +08:00
|
|
|
|
const content = await readPageContentWithWorkspaceFallback({
|
|
|
|
|
|
userId,
|
|
|
|
|
|
storageKey: row.storage_key,
|
|
|
|
|
|
sourceSnapshotJson: row.source_snapshot_json,
|
|
|
|
|
|
pageType: row.page_type,
|
|
|
|
|
|
});
|
2026-06-15 15:04:43 -07:00
|
|
|
|
let snapshot = {};
|
|
|
|
|
|
try {
|
2026-06-30 01:39:47 +08:00
|
|
|
|
snapshot = parseJsonColumn(row.source_snapshot_json);
|
2026-06-15 15:04:43 -07:00
|
|
|
|
} catch {
|
|
|
|
|
|
snapshot = {};
|
|
|
|
|
|
}
|
|
|
|
|
|
const workspaceHtmlRelativePath = snapshot.relative_path ?? null;
|
|
|
|
|
|
const workspacePublishDir = workspaceHtmlRelativePath
|
|
|
|
|
|
? await resolveWorkspacePublishDir(userId)
|
|
|
|
|
|
: null;
|
|
|
|
|
|
return {
|
|
|
|
|
|
row,
|
|
|
|
|
|
content,
|
|
|
|
|
|
workspaceHtmlRelativePath,
|
|
|
|
|
|
workspacePublishDir,
|
|
|
|
|
|
thumbnailStorageKey: pageThumbnailKey(userId, pageId),
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const syncWorkspaceThumbnail = async ({
|
|
|
|
|
|
workspacePublishDir,
|
|
|
|
|
|
workspaceHtmlRelativePath,
|
|
|
|
|
|
html,
|
|
|
|
|
|
meta,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
if (!workspacePublishDir || !workspaceHtmlRelativePath) return;
|
|
|
|
|
|
await ensureWorkspaceHtmlThumbnail(
|
|
|
|
|
|
workspacePublishDir,
|
|
|
|
|
|
workspaceHtmlRelativePath,
|
|
|
|
|
|
html,
|
|
|
|
|
|
{ ...meta, force: true },
|
|
|
|
|
|
).catch(() => {});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const uploadThumbnail = async (userId, pageId, input = {}) => {
|
|
|
|
|
|
const imageBase64 = String(input.imageBase64 ?? '').trim();
|
|
|
|
|
|
if (!imageBase64) throw pageError('请上传图片', 'thumbnail_image_required');
|
|
|
|
|
|
const buffer = Buffer.from(imageBase64, 'base64');
|
|
|
|
|
|
const coverDataUri = bufferToImageDataUri(buffer);
|
|
|
|
|
|
if (!coverDataUri) throw pageError('图片无效或过大', 'thumbnail_image_invalid');
|
|
|
|
|
|
|
|
|
|
|
|
const ctx = await loadPageThumbnailContext(userId, pageId);
|
|
|
|
|
|
const html = String(input.html ?? ctx.content);
|
|
|
|
|
|
const title = String(input.title ?? ctx.row.title ?? '').trim() || ctx.row.title;
|
|
|
|
|
|
const summary = String(input.summary ?? ctx.row.summary ?? '').trim();
|
|
|
|
|
|
const signals = extractCoverSignals(html, {
|
|
|
|
|
|
title,
|
|
|
|
|
|
subtitle: summary || `草稿 v${asNumber(ctx.row.version_no)}`,
|
|
|
|
|
|
});
|
|
|
|
|
|
const svg = buildFeedThumbnailSvg(signals, { coverDataUri });
|
|
|
|
|
|
await writeThumbnail(storageRoot, ctx.thumbnailStorageKey, svg);
|
|
|
|
|
|
if (ctx.workspacePublishDir && ctx.workspaceHtmlRelativePath) {
|
|
|
|
|
|
await writeThumbnail(
|
|
|
|
|
|
ctx.workspacePublishDir,
|
|
|
|
|
|
workspaceThumbnailRelativePath(ctx.workspaceHtmlRelativePath),
|
|
|
|
|
|
svg,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return { updatedAt: Date.now() };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const regenerateThumbnail = async (userId, pageId, input = {}, options = {}) => {
|
|
|
|
|
|
const ctx = await loadPageThumbnailContext(userId, pageId);
|
|
|
|
|
|
let html = String(input.html ?? ctx.content);
|
|
|
|
|
|
const title = String(input.title ?? ctx.row.title ?? '').trim() || ctx.row.title;
|
|
|
|
|
|
const summary = String(input.summary ?? ctx.row.summary ?? '').trim();
|
|
|
|
|
|
let updatedContent = null;
|
|
|
|
|
|
let metaOverrides = {};
|
|
|
|
|
|
|
|
|
|
|
|
if (input.useAi) {
|
|
|
|
|
|
if (!options.suggestCoverMeta) {
|
|
|
|
|
|
throw pageError('AI 封面生成未启用', 'cover_ai_unavailable');
|
|
|
|
|
|
}
|
|
|
|
|
|
metaOverrides = await options.suggestCoverMeta({
|
|
|
|
|
|
title,
|
|
|
|
|
|
summary,
|
|
|
|
|
|
html,
|
|
|
|
|
|
instruction: input.instruction,
|
|
|
|
|
|
});
|
|
|
|
|
|
updatedContent = upsertMindspaceCoverMeta(html, metaOverrides);
|
|
|
|
|
|
html = updatedContent;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const meta = {
|
|
|
|
|
|
title,
|
|
|
|
|
|
subtitle: summary || `草稿 v${asNumber(ctx.row.version_no)}`,
|
|
|
|
|
|
contentStorageKey: ctx.row.storage_key,
|
|
|
|
|
|
force: true,
|
|
|
|
|
|
...metaOverrides,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
await generateHtmlThumbnail(storageRoot, ctx.thumbnailStorageKey, html, meta);
|
|
|
|
|
|
await syncWorkspaceThumbnail({
|
|
|
|
|
|
workspacePublishDir: ctx.workspacePublishDir,
|
|
|
|
|
|
workspaceHtmlRelativePath: ctx.workspaceHtmlRelativePath,
|
|
|
|
|
|
html,
|
|
|
|
|
|
meta,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
updatedAt: Date.now(),
|
|
|
|
|
|
content: updatedContent,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
createFromChat: (userId, input, source) =>
|
|
|
|
|
|
createVersion(userId, input, {
|
|
|
|
|
|
type: 'chat',
|
|
|
|
|
|
sessionId: source.sessionId,
|
|
|
|
|
|
messageId: source.messageId,
|
|
|
|
|
|
assetId: source.assetId ?? null,
|
|
|
|
|
|
snapshot: source.snapshot,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
createdAt: source.createdAt,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
}),
|
|
|
|
|
|
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' }),
|
2026-06-26 15:19:03 +08:00
|
|
|
|
localizePrivateResources,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
redactPage,
|
|
|
|
|
|
createRedactedCopy: redactPage,
|
|
|
|
|
|
listPages,
|
|
|
|
|
|
findPageBySourceAsset,
|
2026-06-27 21:57:21 +08:00
|
|
|
|
findPageBySourceMessage,
|
2026-06-30 09:30:51 +08:00
|
|
|
|
findPageByRelativePath,
|
2026-07-05 23:14:41 +08:00
|
|
|
|
findPlazaContextByWorkspacePath,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
getPage,
|
|
|
|
|
|
getDeletePreview,
|
|
|
|
|
|
deletePage,
|
|
|
|
|
|
listVersions,
|
2026-06-30 09:30:51 +08:00
|
|
|
|
rewriteHtmlDownloadLinksForPage,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
renderPreview: async (userId, pageId) => {
|
|
|
|
|
|
const page = await getPage(userId, pageId);
|
|
|
|
|
|
const html =
|
|
|
|
|
|
page.contentFormat === 'html'
|
2026-06-30 01:22:38 +08:00
|
|
|
|
? await finalizeHtmlPreview(userId, pageId, page.content)
|
2026-06-15 15:04:43 -07:00
|
|
|
|
: 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'
|
2026-06-30 01:22:38 +08:00
|
|
|
|
? await finalizeHtmlPreview(userId, pageId, content)
|
2026-06-15 15:04:43 -07:00
|
|
|
|
: renderPreviewHtml({
|
|
|
|
|
|
title,
|
|
|
|
|
|
summary,
|
|
|
|
|
|
templateId,
|
|
|
|
|
|
versionNo: page.versionNo,
|
|
|
|
|
|
content,
|
|
|
|
|
|
});
|
|
|
|
|
|
return { html, contentFormat: page.contentFormat };
|
|
|
|
|
|
},
|
|
|
|
|
|
renderThumbnail: async (userId, pageId) => {
|
|
|
|
|
|
const ctx = await loadPageThumbnailContext(userId, pageId);
|
|
|
|
|
|
return ensurePageThumbnail({
|
|
|
|
|
|
storageRoot,
|
|
|
|
|
|
pageThumbnailStorageKey: ctx.thumbnailStorageKey,
|
|
|
|
|
|
html: ctx.content,
|
|
|
|
|
|
meta: {
|
|
|
|
|
|
title: ctx.row.title,
|
|
|
|
|
|
subtitle: ctx.row.summary || `草稿 v${asNumber(ctx.row.version_no)}`,
|
|
|
|
|
|
contentStorageKey: ctx.row.storage_key,
|
|
|
|
|
|
},
|
|
|
|
|
|
workspacePublishDir: ctx.workspacePublishDir,
|
|
|
|
|
|
workspaceHtmlRelativePath: ctx.workspaceHtmlRelativePath,
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
uploadThumbnail,
|
|
|
|
|
|
regenerateThumbnail,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 21:22:43 +08:00
|
|
|
|
async function registerPageArtifactForConversation({
|
|
|
|
|
|
registry,
|
|
|
|
|
|
userId,
|
|
|
|
|
|
source,
|
|
|
|
|
|
page,
|
|
|
|
|
|
contentAssetId,
|
|
|
|
|
|
contentMimeType,
|
|
|
|
|
|
contentBytes,
|
|
|
|
|
|
storageKey,
|
|
|
|
|
|
versionNo,
|
|
|
|
|
|
now,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const sessionId = String(source?.sessionId ?? '').trim();
|
|
|
|
|
|
if (!registry || !sessionId || !page?.id) return null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const packageRecord = await registry.ensurePackage({
|
|
|
|
|
|
userId,
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
title: page.title,
|
|
|
|
|
|
now,
|
|
|
|
|
|
});
|
|
|
|
|
|
const artifact = await registry.recordArtifact({
|
|
|
|
|
|
id: `ca_${page.id}_${page.currentVersionId}`,
|
|
|
|
|
|
packageId: packageRecord.id,
|
|
|
|
|
|
artifactKind: 'page',
|
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
|
assetId: contentAssetId,
|
|
|
|
|
|
pageId: page.id,
|
|
|
|
|
|
messageId: source.messageId ?? page.sourceMessageId ?? null,
|
|
|
|
|
|
displayName: page.title,
|
|
|
|
|
|
mimeType: contentMimeType,
|
|
|
|
|
|
sizeBytes: contentBytes,
|
|
|
|
|
|
storageKey,
|
2026-07-02 22:01:35 +08:00
|
|
|
|
canonicalUrl: `/api/mindspace/v1/pages/${encodeURIComponent(page.id)}/preview`,
|
2026-07-02 21:22:43 +08:00
|
|
|
|
sortOrder: Number.isFinite(Number(versionNo)) ? Number(versionNo) : 0,
|
|
|
|
|
|
now,
|
|
|
|
|
|
});
|
|
|
|
|
|
await registry.writeManifestForSession({ userId, sessionId });
|
|
|
|
|
|
return artifact;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn(
|
|
|
|
|
|
'[MindSpace] conversation package page artifact registration failed:',
|
|
|
|
|
|
error instanceof Error ? error.message : error,
|
|
|
|
|
|
);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
export const pageInternals = {
|
|
|
|
|
|
escapeHtml,
|
|
|
|
|
|
normalizePageInput,
|
2026-06-30 01:22:38 +08:00
|
|
|
|
normalizeListPageFilters,
|
2026-06-30 01:39:47 +08:00
|
|
|
|
parseJsonColumn,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
renderContent,
|
|
|
|
|
|
renderPreviewHtml,
|
|
|
|
|
|
renderHtmlPreview,
|
|
|
|
|
|
previewContentSecurityPolicy,
|
|
|
|
|
|
isHtmlPage,
|
|
|
|
|
|
renderPublicationHtml,
|
2026-07-02 21:22:43 +08:00
|
|
|
|
registerPageArtifactForConversation,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
redactContent,
|
|
|
|
|
|
buildPageDeleteSummary,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-27 21:57:21 +08:00
|
|
|
|
export async function inlinePrivateAssetsInHtml(pool, storageRoot, userId, html) {
|
|
|
|
|
|
const PATTERN =
|
|
|
|
|
|
/(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi;
|
|
|
|
|
|
const resolvedRoot = path.resolve(storageRoot);
|
|
|
|
|
|
const absoluteStoragePath = (key) => {
|
|
|
|
|
|
const resolved = path.resolve(resolvedRoot, key);
|
|
|
|
|
|
if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
|
|
|
|
throw Object.assign(new Error('路径越界'), { code: 'invalid_storage_path' });
|
|
|
|
|
|
}
|
|
|
|
|
|
return resolved;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const matches = [...String(html).matchAll(PATTERN)];
|
|
|
|
|
|
if (matches.length === 0) return { html, count: 0 };
|
|
|
|
|
|
|
|
|
|
|
|
const assetIds = [...new Set(matches.map((m) => m[1]).filter(Boolean))];
|
|
|
|
|
|
const [assets] = await pool.query(
|
|
|
|
|
|
`SELECT a.id, a.mime_type, a.original_filename, v.storage_key
|
|
|
|
|
|
FROM h5_assets a
|
|
|
|
|
|
JOIN h5_asset_versions v ON v.id = a.current_version_id
|
|
|
|
|
|
WHERE a.user_id = ? AND a.id IN (?) AND a.mime_type LIKE 'image/%'
|
|
|
|
|
|
AND a.status <> 'deleted'`,
|
|
|
|
|
|
[userId, assetIds],
|
|
|
|
|
|
);
|
|
|
|
|
|
const dataUriMap = new Map();
|
|
|
|
|
|
for (const asset of assets) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const buffer = await fs.readFile(absoluteStoragePath(asset.storage_key));
|
|
|
|
|
|
dataUriMap.set(asset.id, `data:${asset.mime_type};base64,${buffer.toString('base64')}`);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// skip unreadable assets
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let count = 0;
|
|
|
|
|
|
const result = String(html).replace(PATTERN, (_match, assetId) => {
|
|
|
|
|
|
const uri = dataUriMap.get(assetId);
|
|
|
|
|
|
if (!uri) return '';
|
|
|
|
|
|
count += 1;
|
|
|
|
|
|
return uri;
|
|
|
|
|
|
});
|
|
|
|
|
|
return { html: result, count };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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 任务结果关联将被清除`);
|
|
|
|
|
|
}
|
2026-06-30 01:22:38 +08:00
|
|
|
|
if (preview.plazaPosts?.length > 0) {
|
|
|
|
|
|
lines.push(
|
|
|
|
|
|
`该页面在广场有 ${preview.plazaPosts.length} 个帖子;可在下方选择是否一并从广场移除`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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`;
|
|
|
|
|
|
}
|