Compare commits

..

2 Commits

Author SHA1 Message Date
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
tkmind dd091e6e2c Merge pull request 'feat(chat): Plaza 一键发布 + publication RPC 错误映射' (#5) from feature/rpc-publication-errors into main
Merge pull request #5: Plaza 一键发布 + publication RPC 错误映射
2026-07-05 12:01:42 +00:00
4 changed files with 114 additions and 4 deletions
+9
View File
@@ -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) {
+9
View File
@@ -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) {
+24 -1
View File
@@ -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,
+72 -3
View File
@@ -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,