Files
memind/plaza-ops.mjs
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

460 lines
17 KiB
JavaScript

import crypto from 'node:crypto';
const REPORT_REASONS = new Set(['spam', 'violence', 'porn', 'political', 'privacy', 'other']);
const FEATURED_POSITIONS = new Set(['homepage_banner', 'category_top', 'trending']);
const OPS_ROLE_RANK = { none: 0, reviewer: 1, editor: 2, ops_admin: 3 };
function opsError(message, code, details) {
return Object.assign(new Error(message), { code, details });
}
function clampLimit(value, fallback = 20, max = 100) {
const limit = Number(value ?? fallback);
if (!Number.isFinite(limit) || limit < 1) return fallback;
return Math.min(Math.floor(limit), max);
}
export function hasOpsRole(role, minimum) {
return (OPS_ROLE_RANK[role] ?? 0) >= (OPS_ROLE_RANK[minimum] ?? 0);
}
export const opsInternals = {
REPORT_REASONS,
FEATURED_POSITIONS,
hasOpsRole,
};
export function createPlazaOpsService(
pool,
{
idFactory = () => crypto.randomUUID(),
formatPostRow,
reviewPost,
invalidateFeedCaches = null,
} = {},
) {
const loadOperatorRole = async (userId) => {
const [rows] = await pool.query(`SELECT ops_role FROM h5_users WHERE id = ? LIMIT 1`, [userId]);
return rows[0]?.ops_role ?? 'none';
};
const writeAuditLog = async (operatorId, action, targetType, targetId, detail) => {
await pool.query(
`INSERT INTO ops_audit_log (id, operator_id, action, target_type, target_id, detail, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[idFactory(), operatorId, action, targetType, targetId, JSON.stringify(detail ?? {}), Date.now()],
);
};
const listCategories = async () => {
const [rows] = await pool.query(
`SELECT id, name, slug, icon
FROM plaza_categories
WHERE is_active = 1
ORDER BY sort_order ASC, name ASC`,
);
return rows.map((row) => ({
id: row.id,
name: row.name,
slug: row.slug,
icon: row.icon ?? '',
}));
};
const listReviewQueue = async ({
status = 'pending_review',
cursor = null,
limit = 20,
keyword = null,
category = null,
} = {}) => {
const pageLimit = clampLimit(limit, 20, 100);
const params = [status];
let cursorClause = '';
if (cursor) {
cursorClause = 'AND pp.published_at < (SELECT published_at FROM plaza_posts WHERE id = ? LIMIT 1)';
params.push(cursor);
}
let keywordClause = '';
if (keyword) {
keywordClause = 'AND (pp.title LIKE ? OR pp.user_display_name LIKE ? OR pp.user_slug LIKE ?)';
const pattern = `%${String(keyword).trim()}%`;
params.push(pattern, pattern, pattern);
}
let categoryClause = '';
const categorySlug = String(category ?? '').trim();
if (categorySlug) {
categoryClause = 'AND c.slug = ?';
params.push(categorySlug);
}
params.push(pageLimit + 1);
const [rows] = await pool.query(
`SELECT pp.id, pp.title, pp.summary, pp.cover_url, pp.status, pp.user_display_name, pp.user_slug,
pp.published_at, pp.created_at, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon
FROM plaza_posts pp
JOIN plaza_categories c ON c.id = pp.category_id
WHERE pp.status = ? ${cursorClause} ${keywordClause} ${categoryClause}
ORDER BY pp.published_at DESC, pp.id DESC
LIMIT ?`,
params,
);
const hasMore = rows.length > pageLimit;
const pageRows = hasMore ? rows.slice(0, pageLimit) : rows;
const now = Date.now();
const posts = pageRows.map((row) => ({
id: row.id,
title: row.title,
summary: row.summary ?? '',
cover_url: row.cover_url ?? '',
status: row.status,
author: { display_name: row.user_display_name, slug: row.user_slug },
category: { name: row.category_name, slug: row.category_slug, icon: row.category_icon ?? '' },
published_at: new Date(Number(row.published_at)).toISOString(),
sla_warning: status === 'pending_review' && now - Number(row.published_at) > 2 * 3_600_000,
}));
return {
posts,
next_cursor: hasMore ? pageRows[pageRows.length - 1].id : null,
has_more: hasMore,
};
};
const reviewPostAsOps = async (operatorId, postId, action, { reason = null } = {}) => {
const [beforeRows] = await pool.query(`SELECT status FROM plaza_posts WHERE id = ? LIMIT 1`, [postId]);
const before = beforeRows[0];
if (!before) throw opsError('帖子不存在', 'POST_NOT_FOUND');
const result = await reviewPost(postId, action, { reason });
await writeAuditLog(operatorId, `${action}_post`, 'post', postId, {
before_status: before.status,
after_status: result.status,
reason: reason ?? null,
});
return result;
};
const batchReviewPosts = async (operatorId, { post_ids: postIds, action, reason = null } = {}) => {
const ids = Array.isArray(postIds) ? postIds.map((id) => String(id).trim()).filter(Boolean) : [];
if (ids.length === 0) throw opsError('post_ids 不能为空', 'invalid_input');
if (ids.length > 50) throw opsError('单次最多审核 50 条', 'invalid_input');
const posts = [];
for (const postId of ids) {
posts.push(await reviewPostAsOps(operatorId, postId, action, { reason }));
}
return { posts };
};
const createReport = async (reporterId, { target_type: targetType, target_id: targetId, reason, detail }) => {
const type = String(targetType ?? '').trim();
if (type !== 'post' && type !== 'comment') {
throw opsError('不支持的举报类型', 'invalid_input');
}
const reasonValue = String(reason ?? '').trim();
if (!REPORT_REASONS.has(reasonValue)) throw opsError('不支持的举报原因', 'invalid_input');
if (type === 'post') {
const [rows] = await pool.query(
`SELECT id FROM plaza_posts WHERE id = ? AND status = 'published' LIMIT 1`,
[targetId],
);
if (!rows[0]) throw opsError('帖子不存在', 'POST_NOT_FOUND');
} else {
const [rows] = await pool.query(
`SELECT id FROM plaza_comments WHERE id = ? AND status = 'visible' LIMIT 1`,
[targetId],
);
if (!rows[0]) throw opsError('评论不存在', 'COMMENT_NOT_FOUND');
}
const id = idFactory();
const now = Date.now();
await pool.query(
`INSERT INTO plaza_reports
(id, target_type, target_id, reporter_id, reason, detail, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)`,
[id, type, targetId, reporterId, reasonValue, String(detail ?? '').trim().slice(0, 500), now],
);
return { id, status: 'pending' };
};
const listReports = async ({ status = 'pending', limit = 50 } = {}) => {
const pageLimit = clampLimit(limit, 50, 100);
const [rows] = await pool.query(
`SELECT r.*,
(SELECT COUNT(*) FROM plaza_reports r2
WHERE r2.target_type = r.target_type AND r2.target_id = r.target_id AND r2.status = 'pending') AS target_report_count
FROM plaza_reports r
WHERE r.status = ?
ORDER BY r.created_at DESC
LIMIT ?`,
[status, pageLimit],
);
return {
reports: rows.map((row) => ({
id: row.id,
target_type: row.target_type,
target_id: row.target_id,
reason: row.reason,
detail: row.detail,
status: row.status,
target_report_count: Number(row.target_report_count ?? 1),
created_at: new Date(Number(row.created_at)).toISOString(),
})),
};
};
const processReport = async (operatorId, reportId, { action, action_taken: actionTaken = '' } = {}) => {
const [rows] = await pool.query(`SELECT * FROM plaza_reports WHERE id = ? LIMIT 1`, [reportId]);
const report = rows[0];
if (!report) throw opsError('举报不存在', 'report_not_found');
const now = Date.now();
const status = action === 'dismiss' ? 'dismissed' : 'processed';
await pool.query(
`UPDATE plaza_reports
SET status = ?, processed_by = ?, processed_at = ?, action_taken = ?
WHERE id = ?`,
[status, operatorId, now, String(actionTaken).slice(0, 200), reportId],
);
if (action === 'hide_post' && report.target_type === 'post') {
await pool.query(
`UPDATE plaza_posts SET status = 'hidden', updated_at = ? WHERE id = ?`,
[now, report.target_id],
);
await invalidateFeedCaches?.();
}
await writeAuditLog(operatorId, 'process_report', 'report', reportId, {
action,
target_type: report.target_type,
target_id: report.target_id,
});
return { id: reportId, status };
};
const setFeatured = async (operatorId, { post_id: postId, position, sort_order: sortOrder = 0, expires_at: expiresAt = null }) => {
const pos = String(position ?? '').trim();
if (!FEATURED_POSITIONS.has(pos)) throw opsError('不支持的精选位', 'invalid_input');
const [postRows] = await pool.query(
`SELECT id FROM plaza_posts WHERE id = ? AND status = 'published' LIMIT 1`,
[postId],
);
if (!postRows[0]) throw opsError('帖子不存在或未发布', 'POST_NOT_FOUND');
const now = Date.now();
const id = idFactory();
const expiresMs = expiresAt ? new Date(expiresAt).getTime() : null;
await pool.query(
`INSERT INTO plaza_featured (id, post_id, position, sort_order, starts_at, expires_at, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[id, postId, pos, Number(sortOrder) || 0, now, expiresMs, operatorId, now],
);
await invalidateFeedCaches?.();
await writeAuditLog(operatorId, 'set_featured', 'featured', id, { post_id: postId, position: pos });
return { id, post_id: postId, position: pos };
};
const listFeatured = async () => {
const now = Date.now();
const [rows] = await pool.query(
`SELECT f.*, pp.title, pp.cover_url, pp.user_display_name
FROM plaza_featured f
JOIN plaza_posts pp ON pp.id = f.post_id
WHERE f.starts_at <= ? AND (f.expires_at IS NULL OR f.expires_at > ?)
ORDER BY f.position ASC, f.sort_order ASC, f.created_at DESC`,
[now, now],
);
return {
items: rows.map((row) => ({
id: row.id,
post_id: row.post_id,
position: row.position,
sort_order: row.sort_order,
title: row.title,
cover_url: row.cover_url,
author: row.user_display_name,
expires_at: row.expires_at ? new Date(Number(row.expires_at)).toISOString() : null,
})),
};
};
const removeFeatured = async (operatorId, featuredId) => {
const [result] = await pool.query(`DELETE FROM plaza_featured WHERE id = ?`, [featuredId]);
if ((result.affectedRows ?? 0) === 0) throw opsError('精选不存在', 'featured_not_found');
await invalidateFeedCaches?.();
await writeAuditLog(operatorId, 'remove_featured', 'featured', featuredId, {});
return { id: featuredId };
};
const loadActiveFeaturedPosts = async (viewerId = null) => {
const now = Date.now();
const [rows] = await pool.query(
`SELECT f.position, f.sort_order, pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon
FROM plaza_featured f
JOIN plaza_posts pp ON pp.id = f.post_id AND pp.status = 'published'
JOIN plaza_categories c ON c.id = pp.category_id
WHERE f.starts_at <= ? AND (f.expires_at IS NULL OR f.expires_at > ?)
ORDER BY f.position ASC, f.sort_order ASC`,
[now, now],
);
const grouped = { homepage_banner: [], trending: [], category_top: {} };
for (const row of rows) {
const post = formatPostRow ? formatPostRow(row, { viewerReacted: null }) : row;
if (row.position === 'homepage_banner') grouped.homepage_banner.push(post);
if (row.position === 'trending') grouped.trending.push(post);
if (row.position === 'category_top') {
const slug = row.category_slug;
if (!grouped.category_top[slug]) grouped.category_top[slug] = [];
grouped.category_top[slug].push(post);
}
}
return grouped;
};
const getAnalyticsOverview = async () => {
const now = Date.now();
const dayMs = 86_400_000;
const todayStart = now - (now % dayMs);
const yesterdayStart = todayStart - dayMs;
const [[todayPosts]] = await pool.query(
`SELECT COUNT(*) AS count FROM plaza_posts WHERE status = 'published' AND published_at >= ?`,
[todayStart],
);
const [[yesterdayPosts]] = await pool.query(
`SELECT COUNT(*) AS count FROM plaza_posts WHERE status = 'published' AND published_at >= ? AND published_at < ?`,
[yesterdayStart, todayStart],
);
const [[pendingReview]] = await pool.query(
`SELECT COUNT(*) AS count FROM plaza_posts WHERE status = 'pending_review'`,
);
const [[signupFromPlaza]] = await pool.query(
`SELECT COUNT(*) AS count FROM plaza_attribution_events
WHERE event_type = 'signup' AND utm_source = 'plaza' AND created_at >= ?`,
[todayStart],
);
const [categoryRows] = await pool.query(
`SELECT c.name, COUNT(p.id) AS count
FROM plaza_categories c
LEFT JOIN plaza_posts p ON p.category_id = c.id AND p.status = 'published'
GROUP BY c.id, c.name
ORDER BY count DESC`,
);
const [topCreators] = await pool.query(
`SELECT user_slug AS slug, user_display_name AS display_name, COUNT(*) AS post_count,
SUM(like_count) AS total_likes
FROM plaza_posts
WHERE status = 'published'
GROUP BY user_id, user_slug, user_display_name
ORDER BY total_likes DESC
LIMIT 10`,
);
const [dailyPosts] = await pool.query(
`SELECT DATE(FROM_UNIXTIME(published_at / 1000)) AS day, COUNT(*) AS count
FROM plaza_posts
WHERE status = 'published' AND published_at >= ?
GROUP BY day
ORDER BY day ASC`,
[now - 14 * dayMs],
);
return {
today: {
new_posts: Number(todayPosts?.count ?? 0),
plaza_signups: Number(signupFromPlaza?.count ?? 0),
pending_review: Number(pendingReview?.count ?? 0),
},
yesterday: {
new_posts: Number(yesterdayPosts?.count ?? 0),
},
categories: categoryRows.map((row) => ({
name: row.name,
count: Number(row.count ?? 0),
})),
top_creators: topCreators.map((row) => ({
slug: row.slug,
display_name: row.display_name,
post_count: Number(row.post_count ?? 0),
total_likes: Number(row.total_likes ?? 0),
})),
daily_posts: dailyPosts.map((row) => ({
day: row.day,
count: Number(row.count ?? 0),
})),
};
};
const listCreators = async ({ keyword = null, limit = 50 } = {}) => {
const pageLimit = clampLimit(limit, 50, 100);
const params = [];
let keywordClause = '';
if (keyword) {
keywordClause = 'AND (u.username LIKE ? OR u.display_name LIKE ? OR u.slug LIKE ?)';
const pattern = `%${String(keyword).trim()}%`;
params.push(pattern, pattern, pattern);
}
params.push(pageLimit);
const [rows] = await pool.query(
`SELECT u.id, u.slug, u.username, u.display_name, u.plaza_post_count, u.plaza_follower_count,
u.plaza_verified, u.plaza_post_banned, u.plaza_comment_banned, u.ops_role
FROM h5_users u
WHERE u.plaza_post_count > 0 ${keywordClause}
ORDER BY u.plaza_post_count DESC
LIMIT ?`,
params,
);
return {
creators: rows.map((row) => ({
user_id: row.id,
slug: row.slug || row.username,
display_name: row.display_name || row.username,
post_count: Number(row.plaza_post_count ?? 0),
follower_count: Number(row.plaza_follower_count ?? 0),
verified: Boolean(row.plaza_verified),
post_banned: Boolean(row.plaza_post_banned),
comment_banned: Boolean(row.plaza_comment_banned),
})),
};
};
const updateCreator = async (operatorId, userId, patch) => {
const updates = [];
const params = [];
if (patch?.verified != null) {
updates.push('plaza_verified = ?');
params.push(patch.verified ? 1 : 0);
}
if (patch?.post_banned != null) {
updates.push('plaza_post_banned = ?');
params.push(patch.post_banned ? 1 : 0);
}
if (patch?.comment_banned != null) {
updates.push('plaza_comment_banned = ?');
params.push(patch.comment_banned ? 1 : 0);
}
if (updates.length === 0) throw opsError('没有可更新的字段', 'invalid_input');
updates.push('updated_at = ?');
params.push(Date.now(), userId);
const [result] = await pool.query(
`UPDATE h5_users SET ${updates.join(', ')} WHERE id = ?`,
params,
);
if ((result.affectedRows ?? 0) === 0) throw opsError('用户不存在', 'user_not_found');
await writeAuditLog(operatorId, 'update_creator', 'user', userId, patch);
return { user_id: userId };
};
return {
loadOperatorRole,
hasOpsRole,
listCategories,
listReviewQueue,
reviewPostAsOps,
batchReviewPosts,
createReport,
listReports,
processReport,
setFeatured,
listFeatured,
removeFeatured,
loadActiveFeaturedPosts,
getAnalyticsOverview,
listCreators,
updateCreator,
};
}