diff --git a/admin-routes.mjs b/admin-routes.mjs index f40a2f4..e55da58 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -576,6 +576,14 @@ export function createOpsApi({ jsonBody, plazaOps }) { opsApi.use(jsonBody); opsApi.use(makeRequireOps('reviewer')); + opsApi.get('/categories', async (req, res) => { + try { + return sendData(res, req, { categories: await plazaOps.listCategories() }); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + opsApi.get('/review/queue', async (req, res) => { try { const queue = await plazaOps.listReviewQueue({ @@ -583,6 +591,7 @@ export function createOpsApi({ jsonBody, plazaOps }) { cursor: req.query.cursor ?? null, limit: req.query.limit, keyword: req.query.keyword ?? null, + category: req.query.category ?? null, }); return sendData(res, req, queue); } catch (error) { diff --git a/plaza-api.mjs b/plaza-api.mjs index 1933c10..0c8f3b1 100644 --- a/plaza-api.mjs +++ b/plaza-api.mjs @@ -434,6 +434,14 @@ export function attachPlazaApiRoutes(api, plaza, { sendData, sendError }) { opsApi.use(express.json({ limit: '1mb' })); opsApi.use(makeRequireOps('reviewer')); + opsApi.get('/categories', async (req, res) => { + try { + return sendData(res, req, { categories: await ops.listCategories() }); + } catch (error) { + return routeError(res, req, error); + } + }); + opsApi.get('/review/queue', async (req, res) => { try { const queue = await ops.listReviewQueue({ @@ -441,6 +449,7 @@ export function attachPlazaApiRoutes(api, plaza, { sendData, sendError }) { cursor: req.query.cursor ?? null, limit: req.query.limit, keyword: req.query.keyword ?? null, + category: req.query.category ?? null, }); return sendData(res, req, queue); } catch (error) { diff --git a/plaza-ops.mjs b/plaza-ops.mjs index c5fc28f..d6829b1 100644 --- a/plaza-ops.mjs +++ b/plaza-ops.mjs @@ -46,11 +46,27 @@ export function createPlazaOpsService( ); }; + 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]; @@ -65,13 +81,19 @@ export function createPlazaOpsService( 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} + WHERE pp.status = ? ${cursorClause} ${keywordClause} ${categoryClause} ORDER BY pp.published_at DESC, pp.id DESC LIMIT ?`, params, @@ -419,6 +441,7 @@ export function createPlazaOpsService( return { loadOperatorRole, hasOpsRole, + listCategories, listReviewQueue, reviewPostAsOps, batchReviewPosts, diff --git a/plaza-posts.mjs b/plaza-posts.mjs index 049a087..9e5b47c 100644 --- a/plaza-posts.mjs +++ b/plaza-posts.mjs @@ -251,7 +251,69 @@ export function createPlazaPostService( }; }; - const createPost = async (userId, input) => { + 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'); @@ -269,7 +331,7 @@ export function createPlazaPostService( const allowComment = input?.allow_comment == null ? true : Boolean(input.allow_comment); const now = Date.now(); const postId = idFactory(); - const status = autoApprove ? 'published' : 'pending_review'; + const status = autoApprove || options.forcePublished ? 'published' : 'pending_review'; const hotScore = status === 'published' ? await resolveHotScore(now) : 0; const conn = await pool.getConnection(); @@ -317,7 +379,11 @@ export function createPlazaPostService( if (status === 'published') { await plazaRedis?.invalidateFeedCaches?.(); - await onPostPublished?.(postId); + if (options.deferPostPublishedHooks) { + void onPostPublished?.(postId); + } else { + await onPostPublished?.(postId); + } } return { id: postId, status }; @@ -591,6 +657,7 @@ export function createPlazaPostService( `UPDATE plaza_posts SET status = 'hidden', updated_at = ? WHERE id = ?`, [now, postId], ); + await plazaRedis?.invalidateFeedCaches?.(); return { id: postId, status: 'hidden' }; } throw plazaError('不支持的审核操作', 'invalid_input'); @@ -619,6 +686,8 @@ export function createPlazaPostService( ensureCategories, listCategories, createPost, + publishPostForPublication, + findPostByPublicationId, updatePost, hidePost, hidePostsByPublicationId,