Files
memind/plaza-posts.mjs
T
john 0c2cb9e283 feat(plaza-ops): add post management API for admin console
Expose categories listing, category filter on review queue, and invalidate
feed caches when ops hides a published post.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 20:18:02 +08:00

727 lines
23 KiB
JavaScript

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 };