0c2cb9e283
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>
727 lines
23 KiB
JavaScript
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 };
|