From 6a5490dea45e6f28623b67aadfdfdf8b431bcfc6 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Sat, 11 Apr 2026 21:17:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=A2=98=E7=9B=AE?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=8F=98=E6=9B=B4=E6=8E=A5=E5=8F=A3=EF=BC=88?= =?UTF-8?q?=E5=B8=A6=E6=B5=81=E8=BD=AC=E6=A0=A1=E9=AA=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 PATCH /admin/questions/:id/status 接口,支持题目状态流转并校验合法性: - draft → reviewing, archived - reviewing → published, draft, archived - published → archived - archived → draft --- docs/api-reference.md | 70 ++++++++++++++++++++++++++ src/routes/admin/questions.ts | 24 +++++++++ src/services/admin/question-service.ts | 24 +++++++++ 3 files changed, 118 insertions(+) diff --git a/docs/api-reference.md b/docs/api-reference.md index c032599..21d79b7 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1193,6 +1193,75 @@ --- +#### PATCH /admin/questions/:id/status + +变更题目状态(带流转校验)。 + +**认证**: Admin Token + +**路径参数**: +- `id`: 题目 ID + +**请求体**: +```json +{ + "status": "draft | reviewing | published | archived (必填)" +} +``` + +**允许的状态流转**: +| 当前状态 | 可变更到 | +|----------|----------| +| draft | reviewing, archived | +| reviewing | published, draft, archived | +| published | archived | +| archived | draft | + +**响应**: +```json +{ + "success": true, + "data": { + "id": "uuid", + "stem": { "text": "题目内容" }, + "contentType": "text", + "correctAnswer": "B", + "distractors": ["A", "C", "D"], + "categoryId": "uuid", + "difficulty": 3, + "status": "published", + "knowledgeCard": { ... } + }, + "error": null +} +``` + +**错误 (404)**: +```json +{ + "success": false, + "data": null, + "error": { + "code": "NOT_FOUND", + "message": "题目不存在" + } +} +``` + +**错误 (400)**: +```json +{ + "success": false, + "data": null, + "error": { + "code": "INVALID_STATUS_TRANSITION", + "message": "不允许从 published 变更为 reviewing" + } +} +``` + +--- + #### POST /admin/questions/batch-publish 批量发布题目。 @@ -1527,6 +1596,7 @@ | UNAUTHORIZED | 未认证或认证失败 | | FORBIDDEN | 权限不足(需要 super_admin) | | NOT_FOUND | 资源不存在 | +| INVALID_STATUS_TRANSITION | 题目状态流转不合法 | | INVALID_RECEIPT | 支付收据验证失败 | | NOT_IMPLEMENTED | 功能未实现 | | INTERNAL_ERROR | 服务器内部错误 | diff --git a/src/routes/admin/questions.ts b/src/routes/admin/questions.ts index 802e64f..a8b1cbc 100644 --- a/src/routes/admin/questions.ts +++ b/src/routes/admin/questions.ts @@ -65,6 +65,30 @@ export async function adminQuestionsRoutes(app: FastifyInstance): Promise return { success: true, data, error: null }; }); + app.patch('/:id/status', async (request, reply) => { + const { id } = request.params as { id: string }; + const parsed = z.object({ + status: z.enum(['draft', 'reviewing', 'published', 'archived']), + }).safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ success: false, data: null, error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message } }); + } + try { + const data = await questionService.updateQuestionStatus(id, parsed.data.status); + return { success: true, data, error: null }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg === 'QUESTION_NOT_FOUND') { + return reply.status(404).send({ success: false, data: null, error: { code: 'NOT_FOUND', message: '题目不存在' } }); + } + if (msg.startsWith('INVALID_STATUS_TRANSITION:')) { + const [, from, to] = msg.split(':'); + return reply.status(400).send({ success: false, data: null, error: { code: 'INVALID_STATUS_TRANSITION', message: `不允许从 ${from} 变更为 ${to}` } }); + } + throw err; + } + }); + app.delete('/:id', async (request) => { const { id } = request.params as { id: string }; await questionService.archiveQuestion(id); diff --git a/src/services/admin/question-service.ts b/src/services/admin/question-service.ts index d717375..6463d2a 100644 --- a/src/services/admin/question-service.ts +++ b/src/services/admin/question-service.ts @@ -101,6 +101,30 @@ export async function archiveQuestion(id: string) { await db.update(questions).set({ status: 'archived' }).where(eq(questions.id, id)); } +type QuestionStatus = 'draft' | 'reviewing' | 'published' | 'archived'; + +const ALLOWED_TRANSITIONS: Record = { + draft: ['reviewing', 'archived'], + reviewing: ['published', 'draft', 'archived'], + published: ['archived'], + archived: ['draft'], +}; + +export async function updateQuestionStatus(id: string, newStatus: QuestionStatus) { + const [question] = await db.select().from(questions).where(eq(questions.id, id)).limit(1); + if (!question) { + throw new Error('QUESTION_NOT_FOUND'); + } + + const currentStatus = question.status as QuestionStatus; + if (!ALLOWED_TRANSITIONS[currentStatus]?.includes(newStatus)) { + throw new Error(`INVALID_STATUS_TRANSITION:${currentStatus}:${newStatus}`); + } + + await db.update(questions).set({ status: newStatus }).where(eq(questions.id, id)); + 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));