feat: 添加题目批量发布、归档和删除接口

- 新增 batchUpdateStatus 通用方法,带状态流转校验和 BatchResult 报告
- 改造 batchPublish 使用新方法,返回成功/失败详情
- 新增 batchArchive 和 batch-delete 端点(软删除)
- 使用 inArray 批量查询和更新,优化数据库往返
- 更新 API 文档,补充三个批量接口说明
This commit is contained in:
Wang Zhuoxuan 2026-04-11 22:19:02 +08:00
parent 6a5490dea4
commit 1b142f2866
3 changed files with 132 additions and 11 deletions

View File

@ -1264,7 +1264,7 @@
#### POST /admin/questions/batch-publish #### POST /admin/questions/batch-publish
批量发布题目。 批量发布题目(带状态流转校验,仅 reviewing 状态可发布)
**认证**: Admin Token **认证**: Admin Token
@ -1275,15 +1275,77 @@
} }
``` ```
**参数说明**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| ids | string[] | 是 | 题目 ID 数组1-200 个,每个为合法 UUID |
**响应**: **响应**:
```json ```json
{ {
"success": true, "success": true,
"data": null, "data": {
"total": 3,
"succeeded": 2,
"failed": [
{ "id": "uuid3", "reason": "不允许从 draft 变更为 published" }
]
},
"error": null "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` 格式。
--- ---
### 分类管理 ### 分类管理

View File

@ -26,7 +26,7 @@ const updateQuestionSchema = z.object({
status: z.enum(['draft', 'reviewing', 'published', 'archived']).optional(), 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<void> { export async function adminQuestionsRoutes(app: FastifyInstance): Promise<void> {
app.get('/', async (request) => { app.get('/', async (request) => {
@ -96,11 +96,29 @@ export async function adminQuestionsRoutes(app: FastifyInstance): Promise<void>
}); });
app.post('/batch-publish', async (request) => { app.post('/batch-publish', async (request) => {
const parsed = batchPublishSchema.safeParse(request.body); const parsed = batchIdsSchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
return { success: false, data: null, error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message } }; return { success: false, data: null, error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message } };
} }
await questionService.batchPublish(parsed.data.ids); const data = await questionService.batchPublish(parsed.data.ids);
return { success: true, data: null, error: null }; 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 };
}); });
} }

View File

@ -1,6 +1,6 @@
import { db } from '../../db/client.js'; import { db } from '../../db/client.js';
import { questions, knowledgeCards } from '../../db/schema.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'; import { v4 as uuid } from 'uuid';
interface ListOptions { interface ListOptions {
@ -125,8 +125,49 @@ export async function updateQuestionStatus(id: string, newStatus: QuestionStatus
return getQuestionById(id); return getQuestionById(id);
} }
export async function batchPublish(ids: string[]) { export interface BatchResult {
for (const id of ids) { total: number;
await db.update(questions).set({ status: 'published' }).where(eq(questions.id, id)); succeeded: number;
failed: Array<{ id: string; reason: string }>;
}
export async function batchUpdateStatus(ids: string[], targetStatus: QuestionStatus): Promise<BatchResult> {
// 去重,避免重复计数
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<string, QuestionStatus>();
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<BatchResult> {
return batchUpdateStatus(ids, 'published');
}
export async function batchArchive(ids: string[]): Promise<BatchResult> {
return batchUpdateStatus(ids, 'archived');
}