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>
460 lines
17 KiB
JavaScript
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,
|
|
};
|
|
}
|