feat: 添加题目状态变更接口(带流转校验)

新增 PATCH /admin/questions/:id/status 接口,支持题目状态流转并校验合法性:
- draft → reviewing, archived
- reviewing → published, draft, archived
- published → archived
- archived → draft
This commit is contained in:
Wang Zhuoxuan 2026-04-11 21:17:34 +08:00
parent f260fd6bfb
commit 6a5490dea4
3 changed files with 118 additions and 0 deletions

View File

@ -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 | 服务器内部错误 |

View File

@ -65,6 +65,30 @@ export async function adminQuestionsRoutes(app: FastifyInstance): Promise<void>
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);

View File

@ -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<QuestionStatus, QuestionStatus[]> = {
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));