feat(mindspace): 公开页分享组件、workspace 路径与 Plaza 发布增强

- 新增 public share widget 与 workspace relative path 解析
- 增强 publications/chat-plaza 发布链路与缩略图 demo
- 补充 schema、cover 检查脚本与回归测试

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-05 23:14:41 +08:00
parent 69be1e5293
commit e2ad3bf62b
39 changed files with 2184 additions and 256 deletions
+130 -8
View File
@@ -250,6 +250,16 @@ function replaceImgproxyStandardImageReferences(html, publicBaseUrl) {
);
}
/** Fix legacy publication HTML that dropped the public/ segment from MindSpace image URLs. */
export function rewriteBrokenMindSpacePublicImageUrls(html) {
const source = String(html ?? '');
if (!source.includes('/images/')) return source;
return source.replace(
/((?:https?:\/\/[^/"'<>?\s,)]+)?\/MindSpace\/[^/"'<>?\s,)]+)\/images\/(\d{4}-\d{2}-\d{2}\/)/gi,
'$1/public/images/$2',
);
}
function workspacePublicAssetRelativeUrl(htmlRelativePath, assetRelativePath) {
const htmlPath = String(htmlRelativePath ?? '').replace(/^\/+/, '') || 'public/index.html';
const assetPath = String(assetRelativePath ?? '').replace(/^\/+/, '');
@@ -262,11 +272,26 @@ export function rewriteWorkspacePublicAssetReferences(html, htmlRelativePath) {
const source = String(html ?? '');
if (!source) return source;
return source.replace(
/public\/((?:images|\.tmp-images)\/[^"'<>@\s)]+(?:\?[^"'<>)\s]*)?)/gi,
/(?<![/:])public\/((?:images|\.tmp-images)\/[^"'<>@\s)]+(?:\?[^"'<>)\s]*)?)/gi,
(value, assetRelativePath) => workspacePublicAssetRelativeUrl(htmlRelativePath, `public/${assetRelativePath}`),
);
}
const PUBLICATION_WORKSPACE_ASSET_PATTERN =
/(["'(\s])(?:\.\.\/)*(?:public\/)?((?:\.tmp-images|images\/\d{4}-\d{2}-\d{2})\/[^"'<>)\s]+)/gi;
/** Rewrite workspace-relative image paths to canonical /u/{slug}/public/... URLs for /u/.../pages/ routes. */
export function rewritePublicationCanonicalAssetUrls(html, ownerSlug) {
const slug = String(ownerSlug ?? '').trim();
if (!slug) return String(html ?? '');
const source = String(html ?? '');
if (!source.includes('.tmp-images/') && !source.includes('images/')) return source;
const base = `/u/${encodeURIComponent(slug)}/public/`;
return source.replace(PUBLICATION_WORKSPACE_ASSET_PATTERN, (_value, prefix, assetPath) => {
return `${prefix}${base}${assetPath}`;
});
}
async function localizePrivateImageReferences({
pool,
userId,
@@ -297,7 +322,9 @@ async function localizePrivateImageReferences({
if (!asset) continue;
if (imgproxySigner) {
replacements.set(assetId, imgproxySigner.buildUrl(imgproxySigner.baseUrl, asset.storage_key, 'display'));
const storageKey = String(asset.storage_key ?? '').trim();
if (!storageKey) continue;
replacements.set(assetId, imgproxySigner.buildUrl(storageKey, 'display'));
} else {
const mimeType = String(asset.mime_type || 'application/octet-stream');
const buffer = await fs.readFile(absoluteStoragePath(asset.storage_key));
@@ -335,6 +362,12 @@ async function prepareHtmlPublishContent({
absoluteStoragePath,
imgproxySigner,
});
// imgproxy URLs embed /users/... storage keys; convert them to public standard images
// before replacePrivateResourceReferences, which would otherwise treat them as private paths.
publishContent = replaceImgproxyStandardImageReferences(
publishContent,
resolvePublicBaseUrl(),
);
const publishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null;
const resolvedHtmlRelativePath =
htmlRelativePath
@@ -344,6 +377,7 @@ async function prepareHtmlPublishContent({
publishDir,
});
publishContent = rewriteWorkspacePublicAssetReferences(publishContent, resolvedHtmlRelativePath);
publishContent = rewritePublicationCanonicalAssetUrls(publishContent, ownerSlug);
const linkIndex = await loadWorkspaceDownloadLinkIndex(pool, userId);
publishContent = rewriteRelativeDownloadLinks(publishContent, {
htmlRelativePath: resolvedHtmlRelativePath,
@@ -483,7 +517,13 @@ export function createPublicationService(pool, options = {}) {
}),
absoluteStoragePath,
h5Root,
imgproxySigner: imgproxySigner ? { buildUrl: (path, preset) => imgproxySigner.buildUrl(imgproxySigner.baseUrl, path, preset) } : null,
imgproxySigner: imgproxySigner
? {
baseUrl: imgproxySigner.baseUrl,
buildUrl: (storagePath, preset = 'display') =>
imgproxySigner.buildUrl(imgproxySigner.baseUrl, storagePath, preset),
}
: null,
});
};
@@ -535,6 +575,18 @@ export function createPublicationService(pool, options = {}) {
input.expiresAt,
accessMode === 'time_limited',
);
const [onlineRows] = await pool.query(
`SELECT id, url_slug, public_url FROM h5_publish_records
WHERE user_id = ? AND page_id = ? AND status = 'online' LIMIT 1`,
[userId, pageId],
);
const currentOnline = onlineRows[0] ?? null;
if (currentOnline) {
throw publicationError('页面已发布,请先下线后再重新发布', 'page_already_online', {
publicationId: currentOnline.id,
publicUrl: currentOnline.public_url,
});
}
const page = await loadVersion(userId, pageId, input.pageVersionId);
if (page.page_version_id !== page.current_version_id) {
throw publicationError('只能发布页面的当前版本', 'invalid_state_transition');
@@ -632,6 +684,17 @@ export function createPublicationService(pool, options = {}) {
limit: publicPageLimit,
});
}
const [onlineLock] = await conn.query(
`SELECT id, public_url FROM h5_publish_records
WHERE user_id = ? AND page_id = ? AND status = 'online' LIMIT 1 FOR UPDATE`,
[userId, pageId],
);
if (onlineLock[0]) {
throw publicationError('页面已发布,请先下线后再重新发布', 'page_already_online', {
publicationId: onlineLock[0].id,
publicUrl: onlineLock[0].public_url,
});
}
const [categories] = await conn.query(
`SELECT id FROM h5_space_categories
WHERE user_id = ? AND space_id = ? AND category_code = 'public' LIMIT 1`,
@@ -712,11 +775,6 @@ export function createPublicationService(pool, options = {}) {
ORDER BY published_at DESC LIMIT 1 FOR UPDATE`,
[userId, pageId],
);
await conn.query(
`UPDATE h5_publish_records SET status = 'offline', offline_at = ?, updated_at = ?
WHERE user_id = ? AND page_id = ? AND status = 'online'`,
[now, now, userId, pageId],
);
await conn.query(
`UPDATE h5_page_versions
SET immutable = 1, bundle_asset_id = ?, security_scan_id = ?
@@ -850,6 +908,67 @@ export function createPublicationService(pool, options = {}) {
return publicationResponse(rows[0]);
};
const refreshOnlinePublicationHtml = async (userId, pageId) => {
const [pubRows] = await pool.query(
`SELECT pr.id, pr.url_slug, pr.public_url, pr.page_version_id, pr.access_mode,
pr.status, pr.view_count, pr.published_at, pr.offline_at, pr.expires_at,
pv.bundle_asset_id, av.id AS asset_version_id, av.storage_key
FROM h5_publish_records pr
JOIN h5_page_records p ON p.id = pr.page_id AND p.user_id = pr.user_id
JOIN h5_page_versions pv ON pv.id = pr.page_version_id
JOIN h5_assets a ON a.id = pv.bundle_asset_id
JOIN h5_asset_versions av ON av.asset_id = a.id AND av.version_no = 1
WHERE pr.page_id = ? AND pr.user_id = ? AND pr.status = 'online'
ORDER BY pr.published_at DESC
LIMIT 1`,
[pageId, userId],
);
const publication = pubRows[0];
if (!publication) return null;
const [pageRows] = await pool.query(
`SELECT current_version_id FROM h5_page_records
WHERE id = ? AND user_id = ? AND status <> 'deleted'
LIMIT 1`,
[pageId, userId],
);
const currentVersionId = pageRows[0]?.current_version_id;
if (!currentVersionId) {
throw publicationError('页面不存在', 'page_not_found');
}
const page = await loadVersion(userId, pageId, currentVersionId);
if (page.page_type !== 'html') {
return publicationResponse(publication);
}
const ownerSlug = await loadOwnerSlug(userId);
const publishContent = await preparePublishContent(page, ownerSlug, publication.url_slug);
const html = pageInternals.renderPublicationHtml({ ...page, content: publishContent });
const htmlBytes = Buffer.byteLength(html);
const checksum = crypto.createHash('sha256').update(html).digest('hex');
const writtenPath = absoluteStoragePath(publication.storage_key);
await fs.mkdir(path.dirname(writtenPath), { recursive: true });
await fs.writeFile(writtenPath, html);
const now = Date.now();
await pool.query(
`UPDATE h5_assets
SET size_bytes = ?, checksum = ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[htmlBytes, checksum, now, publication.bundle_asset_id, userId],
);
await pool.query(
`UPDATE h5_asset_versions
SET size_bytes = ?, checksum = ?
WHERE id = ?`,
[htmlBytes, checksum, publication.asset_version_id],
);
return publicationResponse({
...publication,
updated_at: now,
});
};
const updatePublicationStatus = async (userId, publicationId, { accessMode, expiresAt }) => {
const conn = await pool.getConnection();
try {
@@ -1128,6 +1247,7 @@ export function createPublicationService(pool, options = {}) {
check,
publish,
getCurrent,
refreshOnlinePublicationHtml,
getPublicHomepage,
getStats,
offline,
@@ -1231,8 +1351,10 @@ export const publicationInternals = {
publicHomepageResponse,
buildPublicationThumbnailFallback,
replaceImgproxyStandardImageReferences,
rewriteBrokenMindSpacePublicImageUrls,
workspacePublicAssetRelativeUrl,
rewriteWorkspacePublicAssetReferences,
rewritePublicationCanonicalAssetUrls,
localizePrivateImageReferences,
collectPublicationCompanionArtifacts,
prepareHtmlPublishContent,