diff --git a/mindspace-chat-plaza.mjs b/mindspace-chat-plaza.mjs index 0c0099c..5e72f3f 100644 --- a/mindspace-chat-plaza.mjs +++ b/mindspace-chat-plaza.mjs @@ -255,21 +255,43 @@ export async function quickPlazaFromChat({ mindSpacePages, mindSpacePublications, plazaPosts, + ensureWorkspaceHtmlThumbnail, + publishDir, }) { if (!mindSpacePages || !mindSpacePublications || !plazaPosts) { throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' }); } - const { analysis } = bundle; + const { analysis, resolvedHtml } = bundle; const page = await ensureChatPageForPlaza({ userId: user.id, bundle, mindSpacePages, + ensureWorkspaceHtmlThumbnail, publishDir, 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) { await assertWorkspacePathNotAlreadyOnPlaza({ userId: user.id, @@ -372,6 +394,8 @@ export async function quickPlazaFromPublicHtml({ mindSpacePublications, plazaPosts, readWorkspaceHtml, + ensureWorkspaceHtmlThumbnail, + publishDir, }) { if (!mindSpacePages || !mindSpacePublications || !plazaPosts) { throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' }); @@ -396,11 +420,19 @@ export async function quickPlazaFromPublicHtml({ relativePath: normalized, }); + const title = titleFromPublicHtml(content, normalized); + if (ensureWorkspaceHtmlThumbnail && publishDir) { + await ensureWorkspaceHtmlThumbnail(publishDir, normalized, content, { + title, + force: true, + }).catch(() => {}); + } + const page = await ensureWorkspaceHtmlPageForPlaza({ userId: user.id, relativePath: normalized, content, - title: titleFromPublicHtml(content, normalized), + title, mindSpacePages, }); diff --git a/plaza-posts.mjs b/plaza-posts.mjs index 9e5b47c..0f33065 100644 --- a/plaza-posts.mjs +++ b/plaza-posts.mjs @@ -43,23 +43,61 @@ export function defaultPlazaCoverUrl(publicUrl) { if (!value) return ''; const [pathname, suffix = ''] = value.split(/([?#].*)/, 2); 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}`; } +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) { const value = String(inputCoverUrl ?? '').trim(); - if (!value) return defaultPlazaCoverUrl(publicUrl); - if (/^https?:\/\//i.test(value)) return value; + if (!value) return normalizePlazaThumbnailUrl(defaultPlazaCoverUrl(publicUrl)); + if (/^https?:\/\//i.test(value)) return normalizePlazaThumbnailUrl(value); const base = String(publicUrl ?? '').trim(); if (base && /^https?:\/\//i.test(base)) { try { - return new URL(value, base).toString(); + return normalizePlazaThumbnailUrl(new URL(value, base).toString()); } catch { - return value; + return normalizePlazaThumbnailUrl(value); } } - return value; + return normalizePlazaThumbnailUrl(value); } function formatStats(row) { @@ -91,11 +129,18 @@ function promoteFreshPosts(items, now = Date.now()) { function formatPostRow(row, { category, viewerReacted = null, publicationUrl = null } = {}) { const resolvedPublicationUrl = publicationUrl ?? row.public_url ?? null; + const publicationUrlSlug = + row.publication_url_slug ?? row.url_slug ?? null; return { id: row.id, title: row.title, 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 ?? { id: row.category_id, name: row.category_name ?? '', @@ -123,6 +168,8 @@ export const plazaInternals = { clampLimit, normalizeSort, defaultPlazaCoverUrl, + buildPlazaPublicationCoverPath, + resolvePlazaPublicationCoverUrl, resolvePlazaCoverUrl, }; @@ -219,7 +266,7 @@ export function createPlazaPostService( const loadPublicationContext = async (userId, publicationId) => { 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 FROM h5_publish_records pr 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 userSnapshot = await loadUserSnapshot(userId); 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 now = Date.now(); const postId = idFactory(); @@ -391,7 +443,7 @@ export function createPlazaPostService( const updatePost = async (userId, postId, input) => { 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 JOIN h5_publish_records pr ON pr.id = pp.publication_id WHERE pp.id = ? AND pp.user_id = ? LIMIT 1`, @@ -413,7 +465,14 @@ export function createPlazaPostService( } if (input?.cover_url != null) { 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) { updates.push('allow_comment = ?'); @@ -456,7 +515,8 @@ export function createPlazaPostService( const getPostById = async (postId, { viewerId = null, includeHidden = false } = {}) => { const statusClause = includeHidden ? '' : `AND pp.status = 'published'`; 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 JOIN plaza_categories c ON c.id = pp.category_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) { const cached = await plazaRedis.getFeedCache(normalizedSort, categorySlug, null); 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) { const reactionMap = await loadViewerReactions( viewerId, @@ -546,7 +615,8 @@ export function createPlazaPostService( : 'pp.hot_score DESC, pp.id DESC'; 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 JOIN plaza_categories c ON c.id = pp.category_id JOIN h5_publish_records pr ON pr.id = pp.publication_id diff --git a/plaza-posts.test.mjs b/plaza-posts.test.mjs index 5b7310d..6bed41d 100644 --- a/plaza-posts.test.mjs +++ b/plaza-posts.test.mjs @@ -53,6 +53,47 @@ test('resolvePlazaCoverUrl prefers explicit cover urls and falls back to publica plazaInternals.resolvePlazaCoverUrl('', '/u/john/pages/demo'), '/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', () => { diff --git a/server/portal-mindspace-chat-share-routes.mjs b/server/portal-mindspace-chat-share-routes.mjs index 3c7cb9e..36ce8c8 100644 --- a/server/portal-mindspace-chat-share-routes.mjs +++ b/server/portal-mindspace-chat-share-routes.mjs @@ -3,6 +3,8 @@ import { quickPlazaFromChat, quickPlazaFromPublicHtml, } from '../mindspace-chat-plaza.mjs'; +import { resolveMindSpaceUserPublishDir } from '../mindspace-runtime-config.mjs'; +import { ensureWorkspaceHtmlThumbnail } from '../mindspace-workspace-thumbnails.mjs'; function assertRouter(api) { if ( @@ -41,6 +43,8 @@ export function attachPortalMindSpaceChatShareRoutes( getQuickPlazaFromPublicHtmlStatusFn = getQuickPlazaFromPublicHtmlStatus, quickPlazaFromPublicHtmlFn = quickPlazaFromPublicHtml, + ensureWorkspaceHtmlThumbnailFn = ensureWorkspaceHtmlThumbnail, + resolveMindSpaceUserPublishDirFn = resolveMindSpaceUserPublishDir, now = Date.now, logger = console, } = {}, @@ -114,6 +118,7 @@ export function attachPortalMindSpaceChatShareRoutes( h5Root, req.body, ); + const publishDir = resolveMindSpaceUserPublishDirFn(h5Root, req.currentUser); const result = await quickPlazaFromChatFn({ user: req.currentUser, bundle, @@ -121,6 +126,8 @@ export function attachPortalMindSpaceChatShareRoutes( mindSpacePages: pages, mindSpacePublications: publications, plazaPosts, + ensureWorkspaceHtmlThumbnail: ensureWorkspaceHtmlThumbnailFn, + publishDir, }); logger.info('[quick-plaza] ok', { ms: now() - startedAt, @@ -214,6 +221,7 @@ export function attachPortalMindSpaceChatShareRoutes( req.body?.relativePath ?? '', ).trim(); + const publishDir = resolveMindSpaceUserPublishDirFn(h5Root, req.currentUser); const result = await quickPlazaFromPublicHtmlFn({ user: req.currentUser, relativePath, @@ -222,6 +230,8 @@ export function attachPortalMindSpaceChatShareRoutes( plazaPosts, readWorkspaceHtml: (input) => chatSave.readWorkspaceHtml(input), + ensureWorkspaceHtmlThumbnail: ensureWorkspaceHtmlThumbnailFn, + publishDir, }); const plazaUrl = resolvePlazaPostUrl( result.post.id, diff --git a/server/portal-mindspace-chat-share-routes.test.mjs b/server/portal-mindspace-chat-share-routes.test.mjs index 9c5c88d..4bbb4b7 100644 --- a/server/portal-mindspace-chat-share-routes.test.mjs +++ b/server/portal-mindspace-chat-share-routes.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; 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() { const routes = new Map(); @@ -194,6 +196,8 @@ test('quick Plaza from chat preserves dependencies, timing, result, and errors', mindSpacePages: dependencies.services.pages, mindSpacePublications: dependencies.services.publications, plazaPosts: dependencies.services.plazaPosts, + ensureWorkspaceHtmlThumbnail, + publishDir: resolveMindSpaceUserPublishDir('/workspace', req.currentUser), }); const failureApi = createRouterRecorder();