import crypto from 'node:crypto'; import { computeHotScore } from './plaza-algorithm.mjs'; const DEFAULT_CATEGORIES = [ { name: '职场报告', slug: 'work-report', icon: '📊', sort_order: 1 }, { name: '学习笔记', slug: 'study-notes', icon: '📚', sort_order: 2 }, { name: '创意作品', slug: 'creative', icon: '🎨', sort_order: 3 }, { name: '门店展示', slug: 'business', icon: '🏪', sort_order: 4 }, { name: '旅行攻略', slug: 'travel', icon: '✈️', sort_order: 5 }, { name: '数据分析', slug: 'data-analysis', icon: '📈', sort_order: 6 }, { name: '生活记录', slug: 'lifestyle', icon: '🌿', sort_order: 7 }, { name: '其他', slug: 'other', icon: '💡', sort_order: 99 }, ]; function plazaError(message, code, details) { return Object.assign(new Error(message), { code, details }); } function normalizeTags(value) { if (!Array.isArray(value)) return []; const tags = value .map((item) => String(item ?? '').trim()) .filter(Boolean) .slice(0, 5); return tags; } function clampLimit(value, fallback = 20, max = 50) { const limit = Number(value ?? fallback); if (!Number.isFinite(limit) || limit < 1) return fallback; return Math.min(Math.floor(limit), max); } function normalizeSort(value) { const raw = String(value ?? 'recommend').trim().toLowerCase(); if (raw === 'new') return 'new'; if (raw === 'hot') return 'hot'; return 'recommend'; } export function defaultPlazaCoverUrl(publicUrl) { const value = String(publicUrl ?? '').trim(); if (!value) return ''; const [pathname, suffix = ''] = value.split(/([?#].*)/, 2); if (!pathname) return ''; const nextPath = pathname.endsWith('/') ? `${pathname}index.thumbnail.png` : `${pathname}.thumbnail.png`; return `${nextPath}${suffix}`; } export function resolvePlazaCoverUrl(inputCoverUrl, publicUrl) { const value = String(inputCoverUrl ?? '').trim(); if (!value) return defaultPlazaCoverUrl(publicUrl); if (/^https?:\/\//i.test(value)) return value; const base = String(publicUrl ?? '').trim(); if (base && /^https?:\/\//i.test(base)) { try { return new URL(value, base).toString(); } catch { return value; } } return value; } function formatStats(row) { return { view_count: Number(row.view_count ?? 0), like_count: Number(row.like_count ?? 0), collect_count: Number(row.collect_count ?? 0), comment_count: Number(row.comment_count ?? 0), share_count: Number(row.share_count ?? 0), }; } const FRESH_POST_WINDOW_MS = 24 * 60 * 60 * 1000; function promoteFreshPosts(items, now = Date.now()) { const fresh = []; const stable = []; for (const post of items) { const publishedAt = Date.parse(post.published_at); if (Number.isFinite(publishedAt) && now - publishedAt <= FRESH_POST_WINDOW_MS) { fresh.push(post); } else { stable.push(post); } } fresh.sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at)); return [...fresh, ...stable]; } function formatPostRow(row, { category, viewerReacted = null, publicationUrl = null } = {}) { const resolvedPublicationUrl = publicationUrl ?? row.public_url ?? null; return { id: row.id, title: row.title, summary: row.summary ?? '', cover_url: resolvePlazaCoverUrl(row.cover_url, resolvedPublicationUrl), category: category ?? { id: row.category_id, name: row.category_name ?? '', slug: row.category_slug ?? '', icon: row.category_icon ?? '', }, tags: Array.isArray(row.tags) ? row.tags : JSON.parse(row.tags ?? '[]'), author: { user_id: row.user_id, slug: row.user_slug, display_name: row.user_display_name, avatar_url: row.user_avatar_url ?? '', }, stats: formatStats(row), viewer_reacted: viewerReacted, published_at: new Date(Number(row.published_at)).toISOString(), ...(resolvedPublicationUrl ? { publication_url: resolvedPublicationUrl } : {}), ...(row.allow_comment != null ? { allow_comment: Boolean(row.allow_comment) } : {}), ...(row.status ? { status: row.status } : {}), }; } export const plazaInternals = { normalizeTags, clampLimit, normalizeSort, defaultPlazaCoverUrl, resolvePlazaCoverUrl, }; export function createPlazaPostService( pool, { idFactory = () => crypto.randomUUID(), loadViewerReactions = null, plazaRedis = null, algorithmConfig = null, onPostPublished = null, loadFeaturedPosts = null, recommendService = null, } = {}, ) { const autoApprove = String(process.env.PLAZA_AUTO_APPROVE ?? '').toLowerCase() === 'true'; const resolveHotScore = async (publishedAt) => { const config = algorithmConfig ?? { w_view: 0.1, w_like: 3.0, w_comment: 5.0, w_collect: 4.0, decay_base: 2.0, decay_exp: 1.5, }; return computeHotScore( { view_count: 0, like_count: 0, comment_count: 0, collect_count: 0, published_at: publishedAt, }, config, ); }; const ensureCategories = async () => { const [rows] = await pool.query(`SELECT COUNT(*) AS count FROM plaza_categories`); if (Number(rows[0]?.count ?? 0) > 0) return; const now = Date.now(); for (const category of DEFAULT_CATEGORIES) { await pool.query( `INSERT INTO plaza_categories (id, name, slug, icon, description, sort_order, is_active, created_at) VALUES (?, ?, ?, ?, '', ?, 1, ?)`, [idFactory(), category.name, category.slug, category.icon, category.sort_order, now], ); } }; const listCategories = async () => { await ensureCategories(); const [rows] = await pool.query( `SELECT c.id, c.name, c.slug, c.icon, COUNT(p.id) AS post_count FROM plaza_categories c LEFT JOIN plaza_posts p ON p.category_id = c.id AND p.status = 'published' WHERE c.is_active = 1 GROUP BY c.id, c.name, c.slug, c.icon, c.sort_order ORDER BY c.sort_order ASC, c.name ASC`, ); return rows.map((row) => ({ id: row.id, name: row.name, slug: row.slug, icon: row.icon ?? '', post_count: Number(row.post_count ?? 0), })); }; const resolveCategoryId = async (categoryId, categorySlug) => { await ensureCategories(); if (categoryId) { const [rows] = await pool.query( `SELECT id FROM plaza_categories WHERE id = ? AND is_active = 1 LIMIT 1`, [categoryId], ); if (!rows[0]) throw plazaError('分类不存在', 'category_not_found'); return rows[0].id; } if (categorySlug) { const [rows] = await pool.query( `SELECT id FROM plaza_categories WHERE slug = ? AND is_active = 1 LIMIT 1`, [categorySlug], ); if (!rows[0]) throw plazaError('分类不存在', 'category_not_found'); return rows[0].id; } throw plazaError('必须选择分类', 'category_required'); }; const loadPublicationContext = async (userId, publicationId) => { const [rows] = await pool.query( `SELECT pr.id, pr.user_id, pr.status, pr.public_url, 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 WHERE pr.id = ? AND pr.user_id = ? LIMIT 1`, [publicationId, userId], ); const row = rows[0]; if (!row) throw plazaError('发布记录不存在', 'publication_not_found'); if (row.status !== 'online') { throw plazaError('发布源不是在线状态', 'PUBLICATION_NOT_ONLINE'); } return row; }; const loadUserSnapshot = async (userId) => { const [rows] = await pool.query( `SELECT id, slug, display_name, username, plaza_post_banned FROM h5_users WHERE id = ? LIMIT 1`, [userId], ); const row = rows[0]; if (!row) throw plazaError('用户不存在', 'user_not_found'); if (row.plaza_post_banned) throw plazaError('无发帖权限', 'POST_PERMISSION_DENIED'); return { user_slug: row.slug || row.username, user_display_name: row.display_name || row.username, user_avatar_url: '', }; }; const findPostByPublicationId = async (publicationId, { userId = null } = {}) => { const params = [publicationId]; let userClause = ''; if (userId) { userClause = ' AND pp.user_id = ?'; params.push(userId); } const [rows] = await pool.query( `SELECT pp.id, pp.status FROM plaza_posts pp WHERE pp.publication_id = ?${userClause} AND pp.status != 'hidden' LIMIT 1`, params, ); return rows[0] ?? null; }; const republishExistingPost = async (userId, postId, options = {}) => { const now = Date.now(); const status = autoApprove || options.forcePublished ? 'published' : 'pending_review'; const hotScore = status === 'published' ? await resolveHotScore(now) : 0; const [result] = await pool.query( `UPDATE plaza_posts SET status = ?, hot_score = ?, hot_updated_at = ?, updated_at = ?, published_at = ? WHERE id = ? AND user_id = ? AND status IN ('hidden', 'rejected')`, [status, hotScore, status === 'published' ? now : null, now, now, postId, userId], ); if (result.affectedRows === 0) { throw plazaError('帖子不存在或无法重新发布', 'POST_NOT_FOUND'); } if (status === 'published') { await plazaRedis?.invalidateFeedCaches?.(); if (options.deferPostPublishedHooks) { void onPostPublished?.(postId); } else { await onPostPublished?.(postId); } } return { id: postId, status }; }; const publishPostForPublication = async (userId, input, options = {}) => { const publicationId = String(input?.publication_id ?? '').trim(); if (!publicationId) throw plazaError('publication_id 不能为空', 'invalid_input'); const [existingRows] = await pool.query( `SELECT id, status FROM plaza_posts WHERE publication_id = ? AND user_id = ? LIMIT 1`, [publicationId, userId], ); const existing = existingRows[0]; if (existing) { if (existing.status === 'published' || existing.status === 'pending_review') { throw plazaError('该内容已发布到广场', 'ALREADY_PUBLISHED', { post_id: existing.id }); } if (existing.status === 'hidden' || existing.status === 'rejected') { return republishExistingPost(userId, existing.id, options); } } return createPost(userId, input, options); }; const createPost = async (userId, input, options = {}) => { const publicationId = String(input?.publication_id ?? '').trim(); if (!publicationId) throw plazaError('publication_id 不能为空', 'invalid_input'); const [existing] = await pool.query( `SELECT id FROM plaza_posts WHERE publication_id = ? LIMIT 1`, [publicationId], ); if (existing[0]) throw plazaError('该内容已发布到广场', 'ALREADY_PUBLISHED'); const publication = await loadPublicationContext(userId, publicationId); 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 allowComment = input?.allow_comment == null ? true : Boolean(input.allow_comment); const now = Date.now(); const postId = idFactory(); const status = autoApprove || options.forcePublished ? 'published' : 'pending_review'; const hotScore = status === 'published' ? await resolveHotScore(now) : 0; const conn = await pool.getConnection(); try { await conn.beginTransaction(); await conn.query( `INSERT INTO plaza_posts (id, publication_id, user_id, title, summary, cover_url, user_slug, user_display_name, user_avatar_url, category_id, tags, status, allow_comment, hot_score, hot_updated_at, published_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ postId, publicationId, userId, publication.title, publication.summary ?? '', coverUrl, userSnapshot.user_slug, userSnapshot.user_display_name, userSnapshot.user_avatar_url, categoryId, JSON.stringify(tags), status, allowComment ? 1 : 0, hotScore, status === 'published' ? now : null, now, now, now, ], ); await conn.query( `UPDATE h5_users SET plaza_post_count = plaza_post_count + 1, updated_at = ? WHERE id = ?`, [now, userId], ); await conn.commit(); } catch (error) { await conn.rollback(); throw error; } finally { conn.release(); } if (status === 'published') { await plazaRedis?.invalidateFeedCaches?.(); if (options.deferPostPublishedHooks) { void onPostPublished?.(postId); } else { await onPostPublished?.(postId); } } return { id: postId, status }; }; const updatePost = async (userId, postId, input) => { const [rows] = await pool.query( `SELECT pp.*, pr.public_url FROM plaza_posts pp JOIN h5_publish_records pr ON pr.id = pp.publication_id WHERE pp.id = ? AND pp.user_id = ? LIMIT 1`, [postId, userId], ); const post = rows[0]; if (!post) throw plazaError('帖子不存在', 'POST_NOT_FOUND'); const updates = []; const params = []; if (input?.category_id != null || input?.category_slug != null) { const categoryId = await resolveCategoryId(input?.category_id, input?.category_slug); updates.push('category_id = ?'); params.push(categoryId); } if (input?.tags != null) { updates.push('tags = ?'); params.push(JSON.stringify(normalizeTags(input.tags))); } if (input?.cover_url != null) { updates.push('cover_url = ?'); params.push(resolvePlazaCoverUrl(input.cover_url, post.public_url)); } if (input?.allow_comment != null) { updates.push('allow_comment = ?'); params.push(input.allow_comment ? 1 : 0); } if (updates.length === 0) throw plazaError('没有可更新的字段', 'invalid_input'); let status = post.status; if (status === 'rejected') status = 'pending_review'; updates.push('status = ?'); updates.push('updated_at = ?'); params.push(status, Date.now(), postId, userId); await pool.query( `UPDATE plaza_posts SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`, params, ); return { id: postId, status }; }; const hidePost = async (userId, postId) => { const now = Date.now(); const [result] = await pool.query( `UPDATE plaza_posts SET status = 'hidden', updated_at = ? WHERE id = ? AND user_id = ? AND status != 'hidden'`, [now, postId, userId], ); if (result.affectedRows === 0) throw plazaError('帖子不存在', 'POST_NOT_FOUND'); return { id: postId, status: 'hidden' }; }; const hidePostsByPublicationId = async (conn, publicationId, now = Date.now()) => { await conn.query( `UPDATE plaza_posts SET status = 'hidden', updated_at = ? WHERE publication_id = ? AND status != 'hidden'`, [now, publicationId], ); }; 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 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 WHERE pp.id = ? ${statusClause} LIMIT 1`, [postId], ); const row = rows[0]; if (!row) throw plazaError('帖子不存在或已隐藏', 'POST_NOT_FOUND'); let viewerReacted = null; if (viewerId && loadViewerReactions) { const map = await loadViewerReactions(viewerId, [postId]); viewerReacted = map.get(postId) ?? { liked: false, collected: false }; } return formatPostRow(row, { viewerReacted, publicationUrl: row.public_url }); }; const listFeed = async ({ sort = 'recommend', categorySlug = null, cursor = null, limit = 20, viewerId = null, sessionId = null, } = {}) => { await ensureCategories(); const normalizedSort = normalizeSort(sort); const pageLimit = clampLimit(limit); if (normalizedSort === 'recommend' && recommendService) { return recommendService.listRecommendedFeed({ viewerId, sessionId, categorySlug, cursor, limit: pageLimit, }); } if (!cursor && normalizedSort !== 'recommend' && plazaRedis?.getFeedCache) { const cached = await plazaRedis.getFeedCache(normalizedSort, categorySlug, null); if (cached?.posts) { if (viewerId && loadViewerReactions) { const reactionMap = await loadViewerReactions( viewerId, cached.posts.map((post) => post.id), ); cached.posts = cached.posts.map((post) => ({ ...post, viewer_reacted: reactionMap.get(post.id) ?? null, })); } return cached; } } const params = ['published']; let categoryFilter = ''; if (categorySlug) { categoryFilter = 'AND c.slug = ?'; params.push(categorySlug); } let cursorClause = ''; if (cursor) { const [cursorRows] = await pool.query( `SELECT id, hot_score, published_at FROM plaza_posts WHERE id = ? LIMIT 1`, [cursor], ); const cursorRow = cursorRows[0]; if (cursorRow) { if (normalizedSort === 'new') { cursorClause = 'AND (pp.published_at < ? OR (pp.published_at = ? AND pp.id < ?))'; params.push(cursorRow.published_at, cursorRow.published_at, cursor); } else { cursorClause = 'AND (pp.hot_score < ? OR (pp.hot_score = ? AND pp.id < ?))'; params.push(cursorRow.hot_score, cursorRow.hot_score, cursor); } } } const orderBy = normalizedSort === 'new' ? 'pp.published_at DESC, pp.id DESC' : '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 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 WHERE pp.status = ? ${categoryFilter} ${cursorClause} ORDER BY ${orderBy} LIMIT ?`, [...params, pageLimit + 1], ); const hasMore = rows.length > pageLimit; const pageRows = hasMore ? rows.slice(0, pageLimit) : rows; let reactionMap = new Map(); if (viewerId && loadViewerReactions) { reactionMap = await loadViewerReactions( viewerId, pageRows.map((row) => row.id), ); } const posts = pageRows.map((row) => formatPostRow(row, { viewerReacted: viewerId ? reactionMap.get(row.id) ?? null : null, }), ); let featured = { homepage_banner: [], trending: [], category_top: {} }; if (!cursor && loadFeaturedPosts) { featured = await loadFeaturedPosts(viewerId); } let mergedPosts = posts; if (!cursor && normalizedSort === 'hot' && featured.trending.length > 0) { const trending = featured.trending.slice(0, 10); const trendingIds = new Set(trending.map((post) => post.id)); mergedPosts = [...trending, ...posts.filter((post) => !trendingIds.has(post.id))].slice( 0, pageLimit, ); } if ( !cursor && normalizedSort === 'hot' && categorySlug && featured.category_top?.[categorySlug]?.length ) { const tops = featured.category_top[categorySlug].slice(0, 3); const topIds = new Set(tops.map((post) => post.id)); mergedPosts = [...tops, ...mergedPosts.filter((post) => !topIds.has(post.id))].slice( 0, pageLimit, ); } if (!cursor && normalizedSort === 'hot') { mergedPosts = promoteFreshPosts(mergedPosts).slice(0, pageLimit); } const result = { posts: mergedPosts, featured: { homepage_banner: featured.homepage_banner, trending: [] }, next_cursor: hasMore ? pageRows[pageRows.length - 1].id : null, has_more: hasMore, }; if (!cursor && plazaRedis?.setFeedCache) { const ttl = normalizedSort === 'new' ? 60 : 300; await plazaRedis.setFeedCache( normalizedSort, categorySlug, null, { posts: mergedPosts, featured: result.featured, next_cursor: result.next_cursor, has_more: result.has_more, }, ttl, ); } return result; }; const reviewPost = async (postId, action, { reason = null } = {}) => { const now = Date.now(); if (action === 'approve') { const hotScore = await resolveHotScore(now); await pool.query( `UPDATE plaza_posts SET status = 'published', hot_score = ?, hot_updated_at = ?, updated_at = ? WHERE id = ?`, [hotScore, now, now, postId], ); await plazaRedis?.invalidateFeedCaches?.(); await onPostPublished?.(postId); return { id: postId, status: 'published' }; } if (action === 'reject') { if (!reason) throw plazaError('拒绝时必须填写原因', 'invalid_input'); await pool.query( `UPDATE plaza_posts SET status = 'rejected', updated_at = ? WHERE id = ?`, [now, postId], ); return { id: postId, status: 'rejected', reason }; } if (action === 'hide') { await pool.query( `UPDATE plaza_posts SET status = 'hidden', updated_at = ? WHERE id = ?`, [now, postId], ); await plazaRedis?.invalidateFeedCaches?.(); return { id: postId, status: 'hidden' }; } throw plazaError('不支持的审核操作', 'invalid_input'); }; const listPendingPosts = async (limit = 50) => { const [rows] = await pool.query( `SELECT id, title, status, user_display_name, user_slug, published_at FROM plaza_posts WHERE status = 'pending_review' ORDER BY published_at DESC LIMIT ?`, [clampLimit(limit, 50, 100)], ); return rows.map((row) => ({ id: row.id, title: row.title, status: row.status, author: row.user_display_name, author_slug: row.user_slug, published_at: new Date(Number(row.published_at)).toISOString(), })); }; return { ensureCategories, listCategories, createPost, publishPostForPublication, findPostByPublicationId, updatePost, hidePost, hidePostsByPublicationId, getPostById, listFeed, reviewPost, listPendingPosts, }; } export function mapPlazaError(error) { const code = error?.code; const map = { PUBLICATION_NOT_ONLINE: 422, ALREADY_PUBLISHED: 409, POST_NOT_FOUND: 404, POST_PERMISSION_DENIED: 403, publication_not_found: 403, category_not_found: 422, category_required: 422, invalid_input: 422, COMMENT_DISABLED: 422, COMMENT_TOO_LONG: 422, COMMENT_RATE_LIMITED: 429, REPLY_DEPTH_EXCEEDED: 422, SELF_FOLLOW: 422, COMMENT_NOT_FOUND: 404, OPS_PERMISSION_DENIED: 403, report_not_found: 404, featured_not_found: 404, user_not_found: 404, }; return map[code] ?? 500; } export { formatPostRow };