From 1b142f286662df3cde44a12afaf81ccb6250b5bd Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Sat, 11 Apr 2026 22:19:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=A2=98=E7=9B=AE?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=8F=91=E5=B8=83=E3=80=81=E5=BD=92=E6=A1=A3?= =?UTF-8?q?=E5=92=8C=E5=88=A0=E9=99=A4=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 batchUpdateStatus 通用方法,带状态流转校验和 BatchResult 报告 - 改造 batchPublish 使用新方法,返回成功/失败详情 - 新增 batchArchive 和 batch-delete 端点(软删除) - 使用 inArray 批量查询和更新,优化数据库往返 - 更新 API 文档,补充三个批量接口说明 --- docs/api-reference.md | 66 +++++++++++++++++++++++++- src/routes/admin/questions.ts | 26 ++++++++-- src/services/admin/question-service.ts | 51 ++++++++++++++++++-- 3 files changed, 132 insertions(+), 11 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 21d79b7..dc708c0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1264,7 +1264,7 @@ #### POST /admin/questions/batch-publish -批量发布题目。 +批量发布题目(带状态流转校验,仅 reviewing 状态可发布)。 **认证**: Admin Token @@ -1275,15 +1275,77 @@ } ``` +**参数说明**: +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| ids | string[] | 是 | 题目 ID 数组,1-200 个,每个为合法 UUID | + **响应**: ```json { "success": true, - "data": null, + "data": { + "total": 3, + "succeeded": 2, + "failed": [ + { "id": "uuid3", "reason": "不允许从 draft 变更为 published" } + ] + }, "error": null } ``` +**data 字段说明**: +| 字段 | 类型 | 说明 | +|------|------|------| +| total | number | 提交的 ID 总数 | +| succeeded | number | 成功更新的数量 | +| failed | array | 失败记录列表(包含 id 和 reason) | + +--- + +#### POST /admin/questions/batch-archive + +批量归档题目(带状态流转校验,draft/reviewing/published 状态可归档)。 + +**认证**: Admin Token + +**请求体**: +```json +{ + "ids": ["uuid1", "uuid2"] +} +``` + +**参数说明**: +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| ids | string[] | 是 | 题目 ID 数组,1-200 个,每个为合法 UUID | + +**响应**: 与 batch-publish 相同的 `BatchResult` 格式。 + +--- + +#### POST /admin/questions/batch-delete + +批量删除题目(软删除,等同于批量归档)。 + +**认证**: Admin Token + +**请求体**: +```json +{ + "ids": ["uuid1", "uuid2"] +} +``` + +**参数说明**: +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| ids | string[] | 是 | 题目 ID 数组,1-200 个,每个为合法 UUID | + +**响应**: 与 batch-publish 相同的 `BatchResult` 格式。 + --- ### 分类管理 diff --git a/src/routes/admin/questions.ts b/src/routes/admin/questions.ts index a8b1cbc..d90f06a 100644 --- a/src/routes/admin/questions.ts +++ b/src/routes/admin/questions.ts @@ -26,7 +26,7 @@ const updateQuestionSchema = z.object({ status: z.enum(['draft', 'reviewing', 'published', 'archived']).optional(), }); -const batchPublishSchema = z.object({ ids: z.array(z.string().uuid()) }); +const batchIdsSchema = z.object({ ids: z.array(z.string().uuid()).min(1).max(200) }); export async function adminQuestionsRoutes(app: FastifyInstance): Promise { app.get('/', async (request) => { @@ -96,11 +96,29 @@ export async function adminQuestionsRoutes(app: FastifyInstance): Promise }); app.post('/batch-publish', async (request) => { - const parsed = batchPublishSchema.safeParse(request.body); + const parsed = batchIdsSchema.safeParse(request.body); if (!parsed.success) { return { success: false, data: null, error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message } }; } - await questionService.batchPublish(parsed.data.ids); - return { success: true, data: null, error: null }; + const data = await questionService.batchPublish(parsed.data.ids); + return { success: true, data, error: null }; + }); + + app.post('/batch-archive', async (request) => { + const parsed = batchIdsSchema.safeParse(request.body); + if (!parsed.success) { + return { success: false, data: null, error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message } }; + } + const data = await questionService.batchArchive(parsed.data.ids); + return { success: true, data, error: null }; + }); + + app.post('/batch-delete', async (request) => { + const parsed = batchIdsSchema.safeParse(request.body); + if (!parsed.success) { + return { success: false, data: null, error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message } }; + } + const data = await questionService.batchArchive(parsed.data.ids); + return { success: true, data, error: null }; }); } diff --git a/src/services/admin/question-service.ts b/src/services/admin/question-service.ts index 6463d2a..f7e1cf8 100644 --- a/src/services/admin/question-service.ts +++ b/src/services/admin/question-service.ts @@ -1,6 +1,6 @@ import { db } from '../../db/client.js'; import { questions, knowledgeCards } from '../../db/schema.js'; -import { eq, and, sql } from 'drizzle-orm'; +import { eq, and, sql, inArray } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; interface ListOptions { @@ -125,8 +125,49 @@ export async function updateQuestionStatus(id: string, newStatus: QuestionStatus return getQuestionById(id); } -export async function batchPublish(ids: string[]) { - for (const id of ids) { - await db.update(questions).set({ status: 'published' }).where(eq(questions.id, id)); - } +export interface BatchResult { + total: number; + succeeded: number; + failed: Array<{ id: string; reason: string }>; +} + +export async function batchUpdateStatus(ids: string[], targetStatus: QuestionStatus): Promise { + // 去重,避免重复计数 + const uniqueIds = [...new Set(ids)]; + const result: BatchResult = { total: uniqueIds.length, succeeded: 0, failed: [] }; + + const existing = await db.select({ id: questions.id, status: questions.status }) + .from(questions) + .where(inArray(questions.id, uniqueIds)); + const existingMap = new Map(); + for (const row of existing) existingMap.set(row.id, row.status as QuestionStatus); + + // 分类:合法 ID 和失败 ID + const validIds: string[] = []; + for (const id of uniqueIds) { + const currentStatus = existingMap.get(id); + if (!currentStatus) { + result.failed.push({ id, reason: '题目不存在' }); + } else if (!ALLOWED_TRANSITIONS[currentStatus]?.includes(targetStatus)) { + result.failed.push({ id, reason: `不允许从 ${currentStatus} 变更为 ${targetStatus}` }); + } else { + validIds.push(id); + } + } + + // 单次批量 UPDATE + if (validIds.length > 0) { + await db.update(questions).set({ status: targetStatus }).where(inArray(questions.id, validIds)); + result.succeeded = validIds.length; + } + + return result; +} + +export async function batchPublish(ids: string[]): Promise { + return batchUpdateStatus(ids, 'published'); +} + +export async function batchArchive(ids: string[]): Promise { + return batchUpdateStatus(ids, 'archived'); }