fix(plaza): use hero-generated thumbnails and portal cover URLs

Plaza posts now resolve cover_url to /u/{slug}/pages/{urlSlug}.thumbnail.png
instead of MindSpace workspace paths that fail on plaza.tkmind.cn. Public-html
and chat quick-plaza flows regenerate workspace thumbnails with force:true
after hero images are on disk so feed cards embed the generated art.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-29 16:00:00 +08:00
parent 9610bd56cf
commit c446ae0db4
5 changed files with 173 additions and 16 deletions
+35 -3
View File
@@ -255,21 +255,43 @@ export async function quickPlazaFromChat({
mindSpacePages, mindSpacePages,
mindSpacePublications, mindSpacePublications,
plazaPosts, plazaPosts,
ensureWorkspaceHtmlThumbnail,
publishDir,
}) { }) {
if (!mindSpacePages || !mindSpacePublications || !plazaPosts) { if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' }); throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' });
} }
const { analysis } = bundle; const { analysis, resolvedHtml } = bundle;
const page = await ensureChatPageForPlaza({ const page = await ensureChatPageForPlaza({
userId: user.id, userId: user.id,
bundle, bundle,
mindSpacePages, mindSpacePages,
ensureWorkspaceHtmlThumbnail,
publishDir, publishDir,
body, body,
skipThumbnail: true, skipThumbnail: !ensureWorkspaceHtmlThumbnail || !publishDir,
}); });
if (
analysis.contentMode === 'static_html' &&
analysis.relativePath &&
resolvedHtml?.content &&
ensureWorkspaceHtmlThumbnail &&
publishDir
) {
await ensureWorkspaceHtmlThumbnail(
publishDir,
analysis.relativePath,
resolvedHtml.content,
{
title: page.title,
subtitle: page.summary,
force: true,
},
).catch(() => {});
}
if (analysis.contentMode === 'static_html' && analysis.relativePath) { if (analysis.contentMode === 'static_html' && analysis.relativePath) {
await assertWorkspacePathNotAlreadyOnPlaza({ await assertWorkspacePathNotAlreadyOnPlaza({
userId: user.id, userId: user.id,
@@ -372,6 +394,8 @@ export async function quickPlazaFromPublicHtml({
mindSpacePublications, mindSpacePublications,
plazaPosts, plazaPosts,
readWorkspaceHtml, readWorkspaceHtml,
ensureWorkspaceHtmlThumbnail,
publishDir,
}) { }) {
if (!mindSpacePages || !mindSpacePublications || !plazaPosts) { if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' }); throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' });
@@ -396,11 +420,19 @@ export async function quickPlazaFromPublicHtml({
relativePath: normalized, relativePath: normalized,
}); });
const title = titleFromPublicHtml(content, normalized);
if (ensureWorkspaceHtmlThumbnail && publishDir) {
await ensureWorkspaceHtmlThumbnail(publishDir, normalized, content, {
title,
force: true,
}).catch(() => {});
}
const page = await ensureWorkspaceHtmlPageForPlaza({ const page = await ensureWorkspaceHtmlPageForPlaza({
userId: user.id, userId: user.id,
relativePath: normalized, relativePath: normalized,
content, content,
title: titleFromPublicHtml(content, normalized), title,
mindSpacePages, mindSpacePages,
}); });
+83 -13
View File
@@ -43,23 +43,61 @@ export function defaultPlazaCoverUrl(publicUrl) {
if (!value) return ''; if (!value) return '';
const [pathname, suffix = ''] = value.split(/([?#].*)/, 2); const [pathname, suffix = ''] = value.split(/([?#].*)/, 2);
if (!pathname) return ''; if (!pathname) return '';
const nextPath = pathname.endsWith('/') ? `${pathname}index.thumbnail.png` : `${pathname}.thumbnail.png`; const normalizedPath = pathname.replace(/\.html$/i, '');
const nextPath = normalizedPath.endsWith('/')
? `${normalizedPath}index.thumbnail.png`
: `${normalizedPath}.thumbnail.png`;
return `${nextPath}${suffix}`; return `${nextPath}${suffix}`;
} }
function normalizePlazaThumbnailUrl(url) {
const value = String(url ?? '').trim();
if (!value) return value;
return value.replace(/\.html\.thumbnail\.png(?=($|[?#]))/i, '.thumbnail.png');
}
function isMindSpacePlazaCoverUrl(url) {
const value = String(url ?? '').trim();
return /\/MindSpace\//i.test(value);
}
export function buildPlazaPublicationCoverPath(ownerSlug, urlSlug) {
return `/u/${encodeURIComponent(ownerSlug)}/pages/${encodeURIComponent(urlSlug)}.thumbnail.png`;
}
export function resolvePlazaPublicationCoverUrl({
inputCoverUrl,
publicUrl,
publicationUrlSlug = null,
userSlug = null,
} = {}) {
const fallback =
publicationUrlSlug && userSlug
? buildPlazaPublicationCoverPath(userSlug, publicationUrlSlug)
: '';
const explicit = String(inputCoverUrl ?? '').trim();
if (!explicit) {
return resolvePlazaCoverUrl(fallback, fallback.startsWith('/u/') ? '' : publicUrl);
}
if ((isMindSpacePlazaCoverUrl(explicit) || /\.html\.thumbnail\.png/i.test(explicit)) && fallback) {
return resolvePlazaCoverUrl(fallback, '');
}
return resolvePlazaCoverUrl(explicit, publicUrl);
}
export function resolvePlazaCoverUrl(inputCoverUrl, publicUrl) { export function resolvePlazaCoverUrl(inputCoverUrl, publicUrl) {
const value = String(inputCoverUrl ?? '').trim(); const value = String(inputCoverUrl ?? '').trim();
if (!value) return defaultPlazaCoverUrl(publicUrl); if (!value) return normalizePlazaThumbnailUrl(defaultPlazaCoverUrl(publicUrl));
if (/^https?:\/\//i.test(value)) return value; if (/^https?:\/\//i.test(value)) return normalizePlazaThumbnailUrl(value);
const base = String(publicUrl ?? '').trim(); const base = String(publicUrl ?? '').trim();
if (base && /^https?:\/\//i.test(base)) { if (base && /^https?:\/\//i.test(base)) {
try { try {
return new URL(value, base).toString(); return normalizePlazaThumbnailUrl(new URL(value, base).toString());
} catch { } catch {
return value; return normalizePlazaThumbnailUrl(value);
} }
} }
return value; return normalizePlazaThumbnailUrl(value);
} }
function formatStats(row) { function formatStats(row) {
@@ -91,11 +129,18 @@ function promoteFreshPosts(items, now = Date.now()) {
function formatPostRow(row, { category, viewerReacted = null, publicationUrl = null } = {}) { function formatPostRow(row, { category, viewerReacted = null, publicationUrl = null } = {}) {
const resolvedPublicationUrl = publicationUrl ?? row.public_url ?? null; const resolvedPublicationUrl = publicationUrl ?? row.public_url ?? null;
const publicationUrlSlug =
row.publication_url_slug ?? row.url_slug ?? null;
return { return {
id: row.id, id: row.id,
title: row.title, title: row.title,
summary: row.summary ?? '', summary: row.summary ?? '',
cover_url: resolvePlazaCoverUrl(row.cover_url, resolvedPublicationUrl), cover_url: resolvePlazaPublicationCoverUrl({
inputCoverUrl: row.cover_url,
publicUrl: resolvedPublicationUrl,
publicationUrlSlug,
userSlug: row.user_slug,
}),
category: category ?? { category: category ?? {
id: row.category_id, id: row.category_id,
name: row.category_name ?? '', name: row.category_name ?? '',
@@ -123,6 +168,8 @@ export const plazaInternals = {
clampLimit, clampLimit,
normalizeSort, normalizeSort,
defaultPlazaCoverUrl, defaultPlazaCoverUrl,
buildPlazaPublicationCoverPath,
resolvePlazaPublicationCoverUrl,
resolvePlazaCoverUrl, resolvePlazaCoverUrl,
}; };
@@ -219,7 +266,7 @@ export function createPlazaPostService(
const loadPublicationContext = async (userId, publicationId) => { const loadPublicationContext = async (userId, publicationId) => {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT pr.id, pr.user_id, pr.status, pr.public_url, `SELECT pr.id, pr.user_id, pr.status, pr.public_url, pr.url_slug,
p.title, p.summary, p.cover_image_asset_id p.title, p.summary, p.cover_image_asset_id
FROM h5_publish_records pr FROM h5_publish_records pr
JOIN h5_page_records p ON p.id = pr.page_id JOIN h5_page_records p ON p.id = pr.page_id
@@ -327,7 +374,12 @@ export function createPlazaPostService(
const categoryId = await resolveCategoryId(input?.category_id, input?.category_slug); const categoryId = await resolveCategoryId(input?.category_id, input?.category_slug);
const userSnapshot = await loadUserSnapshot(userId); const userSnapshot = await loadUserSnapshot(userId);
const tags = normalizeTags(input?.tags); const tags = normalizeTags(input?.tags);
const coverUrl = resolvePlazaCoverUrl(input?.cover_url, publication.public_url); const coverUrl = resolvePlazaPublicationCoverUrl({
inputCoverUrl: input?.cover_url,
publicUrl: publication.public_url,
publicationUrlSlug: publication.url_slug,
userSlug: userSnapshot.user_slug,
});
const allowComment = input?.allow_comment == null ? true : Boolean(input.allow_comment); const allowComment = input?.allow_comment == null ? true : Boolean(input.allow_comment);
const now = Date.now(); const now = Date.now();
const postId = idFactory(); const postId = idFactory();
@@ -391,7 +443,7 @@ export function createPlazaPostService(
const updatePost = async (userId, postId, input) => { const updatePost = async (userId, postId, input) => {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT pp.*, pr.public_url `SELECT pp.*, pr.public_url, pr.url_slug AS publication_url_slug
FROM plaza_posts pp FROM plaza_posts pp
JOIN h5_publish_records pr ON pr.id = pp.publication_id JOIN h5_publish_records pr ON pr.id = pp.publication_id
WHERE pp.id = ? AND pp.user_id = ? LIMIT 1`, WHERE pp.id = ? AND pp.user_id = ? LIMIT 1`,
@@ -413,7 +465,14 @@ export function createPlazaPostService(
} }
if (input?.cover_url != null) { if (input?.cover_url != null) {
updates.push('cover_url = ?'); updates.push('cover_url = ?');
params.push(resolvePlazaCoverUrl(input.cover_url, post.public_url)); params.push(
resolvePlazaPublicationCoverUrl({
inputCoverUrl: input.cover_url,
publicUrl: post.public_url,
publicationUrlSlug: post.publication_url_slug,
userSlug: post.user_slug,
}),
);
} }
if (input?.allow_comment != null) { if (input?.allow_comment != null) {
updates.push('allow_comment = ?'); updates.push('allow_comment = ?');
@@ -456,7 +515,8 @@ export function createPlazaPostService(
const getPostById = async (postId, { viewerId = null, includeHidden = false } = {}) => { const getPostById = async (postId, { viewerId = null, includeHidden = false } = {}) => {
const statusClause = includeHidden ? '' : `AND pp.status = 'published'`; const statusClause = includeHidden ? '' : `AND pp.status = 'published'`;
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon,
pr.public_url, pr.url_slug AS publication_url_slug
FROM plaza_posts pp FROM plaza_posts pp
JOIN plaza_categories c ON c.id = pp.category_id JOIN plaza_categories c ON c.id = pp.category_id
JOIN h5_publish_records pr ON pr.id = pp.publication_id JOIN h5_publish_records pr ON pr.id = pp.publication_id
@@ -499,6 +559,15 @@ export function createPlazaPostService(
if (!cursor && normalizedSort !== 'recommend' && plazaRedis?.getFeedCache) { if (!cursor && normalizedSort !== 'recommend' && plazaRedis?.getFeedCache) {
const cached = await plazaRedis.getFeedCache(normalizedSort, categorySlug, null); const cached = await plazaRedis.getFeedCache(normalizedSort, categorySlug, null);
if (cached?.posts) { if (cached?.posts) {
cached.posts = cached.posts.map((post) => ({
...post,
cover_url: resolvePlazaPublicationCoverUrl({
inputCoverUrl: post.cover_url,
publicUrl: post.publication_url,
publicationUrlSlug: post.publication_url_slug,
userSlug: post.author?.slug ?? post.user_slug,
}),
}));
if (viewerId && loadViewerReactions) { if (viewerId && loadViewerReactions) {
const reactionMap = await loadViewerReactions( const reactionMap = await loadViewerReactions(
viewerId, viewerId,
@@ -546,7 +615,8 @@ export function createPlazaPostService(
: 'pp.hot_score DESC, pp.id DESC'; : 'pp.hot_score DESC, pp.id DESC';
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon,
pr.public_url, pr.url_slug AS publication_url_slug
FROM plaza_posts pp FROM plaza_posts pp
JOIN plaza_categories c ON c.id = pp.category_id JOIN plaza_categories c ON c.id = pp.category_id
JOIN h5_publish_records pr ON pr.id = pp.publication_id JOIN h5_publish_records pr ON pr.id = pp.publication_id
+41
View File
@@ -53,6 +53,47 @@ test('resolvePlazaCoverUrl prefers explicit cover urls and falls back to publica
plazaInternals.resolvePlazaCoverUrl('', '/u/john/pages/demo'), plazaInternals.resolvePlazaCoverUrl('', '/u/john/pages/demo'),
'/u/john/pages/demo.thumbnail.png', '/u/john/pages/demo.thumbnail.png',
); );
assert.equal(
plazaInternals.defaultPlazaCoverUrl('/MindSpace/user/public/page.html'),
'/MindSpace/user/public/page.thumbnail.png',
);
assert.equal(
plazaInternals.resolvePlazaCoverUrl(
'https://m.tkmind.cn/MindSpace/u/public/demo.html.thumbnail.png',
'/u/john/pages/demo',
),
'https://m.tkmind.cn/MindSpace/u/public/demo.thumbnail.png',
);
});
test('resolvePlazaPublicationCoverUrl prefers portal publication thumbnail over MindSpace cover', () => {
assert.equal(
plazaInternals.resolvePlazaPublicationCoverUrl({
inputCoverUrl: '',
publicUrl: 'https://m.tkmind.cn/MindSpace/wx_ul610et8/public/demo.html',
publicationUrlSlug: 'plaza-batch-001',
userSlug: 'wx_ul610et8',
}),
'/u/wx_ul610et8/pages/plaza-batch-001.thumbnail.png',
);
assert.equal(
plazaInternals.resolvePlazaPublicationCoverUrl({
inputCoverUrl: 'https://m.tkmind.cn/MindSpace/wx_ul610et8/public/demo.html.thumbnail.png',
publicUrl: 'https://m.tkmind.cn/MindSpace/wx_ul610et8/public/demo.html',
publicationUrlSlug: 'plaza-batch-001',
userSlug: 'wx_ul610et8',
}),
'/u/wx_ul610et8/pages/plaza-batch-001.thumbnail.png',
);
assert.equal(
plazaInternals.resolvePlazaPublicationCoverUrl({
inputCoverUrl: 'https://cdn.example.com/cover.png',
publicUrl: '/u/john/pages/demo',
publicationUrlSlug: 'demo',
userSlug: 'john',
}),
'https://cdn.example.com/cover.png',
);
}); });
test('mapPlazaError maps documented API codes', () => { test('mapPlazaError maps documented API codes', () => {
@@ -3,6 +3,8 @@ import {
quickPlazaFromChat, quickPlazaFromChat,
quickPlazaFromPublicHtml, quickPlazaFromPublicHtml,
} from '../mindspace-chat-plaza.mjs'; } from '../mindspace-chat-plaza.mjs';
import { resolveMindSpaceUserPublishDir } from '../mindspace-runtime-config.mjs';
import { ensureWorkspaceHtmlThumbnail } from '../mindspace-workspace-thumbnails.mjs';
function assertRouter(api) { function assertRouter(api) {
if ( if (
@@ -41,6 +43,8 @@ export function attachPortalMindSpaceChatShareRoutes(
getQuickPlazaFromPublicHtmlStatusFn = getQuickPlazaFromPublicHtmlStatusFn =
getQuickPlazaFromPublicHtmlStatus, getQuickPlazaFromPublicHtmlStatus,
quickPlazaFromPublicHtmlFn = quickPlazaFromPublicHtml, quickPlazaFromPublicHtmlFn = quickPlazaFromPublicHtml,
ensureWorkspaceHtmlThumbnailFn = ensureWorkspaceHtmlThumbnail,
resolveMindSpaceUserPublishDirFn = resolveMindSpaceUserPublishDir,
now = Date.now, now = Date.now,
logger = console, logger = console,
} = {}, } = {},
@@ -114,6 +118,7 @@ export function attachPortalMindSpaceChatShareRoutes(
h5Root, h5Root,
req.body, req.body,
); );
const publishDir = resolveMindSpaceUserPublishDirFn(h5Root, req.currentUser);
const result = await quickPlazaFromChatFn({ const result = await quickPlazaFromChatFn({
user: req.currentUser, user: req.currentUser,
bundle, bundle,
@@ -121,6 +126,8 @@ export function attachPortalMindSpaceChatShareRoutes(
mindSpacePages: pages, mindSpacePages: pages,
mindSpacePublications: publications, mindSpacePublications: publications,
plazaPosts, plazaPosts,
ensureWorkspaceHtmlThumbnail: ensureWorkspaceHtmlThumbnailFn,
publishDir,
}); });
logger.info('[quick-plaza] ok', { logger.info('[quick-plaza] ok', {
ms: now() - startedAt, ms: now() - startedAt,
@@ -214,6 +221,7 @@ export function attachPortalMindSpaceChatShareRoutes(
req.body?.relativePath ?? req.body?.relativePath ??
'', '',
).trim(); ).trim();
const publishDir = resolveMindSpaceUserPublishDirFn(h5Root, req.currentUser);
const result = await quickPlazaFromPublicHtmlFn({ const result = await quickPlazaFromPublicHtmlFn({
user: req.currentUser, user: req.currentUser,
relativePath, relativePath,
@@ -222,6 +230,8 @@ export function attachPortalMindSpaceChatShareRoutes(
plazaPosts, plazaPosts,
readWorkspaceHtml: (input) => readWorkspaceHtml: (input) =>
chatSave.readWorkspaceHtml(input), chatSave.readWorkspaceHtml(input),
ensureWorkspaceHtmlThumbnail: ensureWorkspaceHtmlThumbnailFn,
publishDir,
}); });
const plazaUrl = resolvePlazaPostUrl( const plazaUrl = resolvePlazaPostUrl(
result.post.id, result.post.id,
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { attachPortalMindSpaceChatShareRoutes } from './portal-mindspace-chat-share-routes.mjs'; import { attachPortalMindSpaceChatShareRoutes } from './portal-mindspace-chat-share-routes.mjs';
import { resolveMindSpaceUserPublishDir } from '../mindspace-runtime-config.mjs';
import { ensureWorkspaceHtmlThumbnail } from '../mindspace-workspace-thumbnails.mjs';
function createRouterRecorder() { function createRouterRecorder() {
const routes = new Map(); const routes = new Map();
@@ -194,6 +196,8 @@ test('quick Plaza from chat preserves dependencies, timing, result, and errors',
mindSpacePages: dependencies.services.pages, mindSpacePages: dependencies.services.pages,
mindSpacePublications: dependencies.services.publications, mindSpacePublications: dependencies.services.publications,
plazaPosts: dependencies.services.plazaPosts, plazaPosts: dependencies.services.plazaPosts,
ensureWorkspaceHtmlThumbnail,
publishDir: resolveMindSpaceUserPublishDir('/workspace', req.currentUser),
}); });
const failureApi = createRouterRecorder(); const failureApi = createRouterRecorder();