Merge pull request 'feat(plaza-ops): 运营后台帖子管理 API' (#6) from feature/plaza-ops-admin into main
Merge pull request #6: Plaza 运营后台帖子管理 API
This commit was merged in pull request #6.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user