feat: 完善题目列表查询接口,支持搜索、多维筛选和排序

- 新增关键词搜索(同时匹配题干 stem 和选项 distractors)
- 新增按难度(difficulty)、来源(source)筛选
- 新增动态排序:支持 createdAt/updatedAt/difficulty,可选 asc/desc
- 路由层增加 sortBy/sortOrder 白名单校验
This commit is contained in:
Wang Zhuoxuan 2026-04-12 00:04:11 +08:00
parent aeebcba77c
commit db2f3af8a3
3 changed files with 63 additions and 5 deletions

View File

@ -1047,8 +1047,13 @@
**查询参数**: **查询参数**:
- `page`: 页码 (默认: 1) - `page`: 页码 (默认: 1)
- `limit`: 每页数量 (默认: 20) - `limit`: 每页数量 (默认: 20)
- `status`: draft | reviewing | published | archived (可选) - `status`: draft | reviewing | published | archived (可选,按状态筛选)
- `categoryId`: 分类 ID (可选) - `categoryId`: 分类 ID (可选)
- `keyword`: 关键词搜索,匹配题干(stem)和选项(distractors)内容 (可选)
- `difficulty`: 难度值 1-5精确匹配 (可选)
- `source`: system | ugc按来源筛选 (可选)
- `sortBy`: 排序字段createdAt | updatedAt | difficulty (默认: createdAt)
- `sortOrder`: asc | desc (默认: desc)
**响应**: **响应**:
```json ```json

View File

@ -45,12 +45,26 @@ 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) => {
const { page = '1', limit = '20', status, categoryId } = request.query as Record<string, string>; const query = request.query as Record<string, string>;
const { page = '1', limit = '20', status, categoryId, keyword, difficulty, source, sortBy, sortOrder } = query;
if (sortBy && !['createdAt', 'updatedAt', 'difficulty'].includes(sortBy)) {
return { success: false, data: null, error: { code: 'VALIDATION_ERROR', message: 'sortBy 仅支持 createdAt / updatedAt / difficulty' } };
}
if (sortOrder && !['asc', 'desc'].includes(sortOrder)) {
return { success: false, data: null, error: { code: 'VALIDATION_ERROR', message: 'sortOrder 仅支持 asc / desc' } };
}
const result = await questionService.listQuestions({ const result = await questionService.listQuestions({
page: Number(page), page: Number(page),
limit: Number(limit), limit: Number(limit),
status, status,
categoryId, categoryId,
keyword,
difficulty: difficulty !== undefined ? Number(difficulty) : undefined,
source,
sortBy: sortBy as 'createdAt' | 'updatedAt' | 'difficulty' | undefined,
sortOrder: sortOrder as 'asc' | 'desc' | undefined,
}); });
return { success: true, data: result.items, pagination: result.pagination, error: null }; return { success: true, data: result.items, pagination: result.pagination, error: null };
}); });

View File

@ -1,6 +1,6 @@
import { db } from '../../db/client.js'; import { db } from '../../db/client.js';
import { questions, knowledgeCards, categories } from '../../db/schema.js'; import { questions, knowledgeCards, categories } from '../../db/schema.js';
import { eq, and, sql, inArray } from 'drizzle-orm'; import { eq, and, or, sql, inArray } from 'drizzle-orm';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
/** 单条导入数据的输入结构 */ /** 单条导入数据的输入结构 */
@ -124,19 +124,55 @@ function parseCsvRow(line: string): string[] {
return fields; return fields;
} }
type SortField = 'createdAt' | 'updatedAt' | 'difficulty';
type SortOrder = 'asc' | 'desc';
interface ListOptions { interface ListOptions {
page?: number; page?: number;
limit?: number; limit?: number;
status?: string; status?: string;
categoryId?: string; categoryId?: string;
keyword?: string;
difficulty?: number;
source?: string;
sortBy?: SortField;
sortOrder?: SortOrder;
} }
export async function listQuestions({ page = 1, limit = 20, status, categoryId }: ListOptions) { /** 排序字段名映射Drizzle 列 → SQL 列名) */
const SORT_COLUMN_MAP: Record<SortField, string> = {
createdAt: 'created_at',
updatedAt: 'updated_at',
difficulty: 'difficulty',
};
export async function listQuestions({
page = 1,
limit = 20,
status,
categoryId,
keyword,
difficulty,
source,
sortBy = 'createdAt',
sortOrder = 'desc',
}: ListOptions) {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const conditions = []; const conditions = [];
if (status) conditions.push(eq(questions.status, status as 'draft' | 'reviewing' | 'published' | 'archived')); if (status) conditions.push(eq(questions.status, status as 'draft' | 'reviewing' | 'published' | 'archived'));
if (categoryId) conditions.push(eq(questions.categoryId, categoryId)); if (categoryId) conditions.push(eq(questions.categoryId, categoryId));
if (difficulty !== undefined) conditions.push(eq(questions.difficulty, difficulty));
if (source) conditions.push(eq(questions.source, source as 'system' | 'ugc'));
if (keyword) {
const like = `%${keyword}%`;
conditions.push(
or(
sql`CAST(${questions.stem} AS CHAR) LIKE ${like}`,
sql`CAST(${questions.distractors} AS CHAR) LIKE ${like}`,
)!,
);
}
const where = conditions.length > 0 ? and(...conditions) : undefined; const where = conditions.length > 0 ? and(...conditions) : undefined;
@ -146,11 +182,14 @@ export async function listQuestions({ page = 1, limit = 20, status, categoryId }
.where(where); .where(where);
const total = Number(rows?.count ?? 0); const total = Number(rows?.count ?? 0);
const column = SORT_COLUMN_MAP[sortBy] ?? 'created_at';
const direction = sortOrder === 'asc' ? 'ASC' : 'DESC';
const items = await db const items = await db
.select() .select()
.from(questions) .from(questions)
.where(where) .where(where)
.orderBy(sql`created_at DESC`) .orderBy(sql`${sql.raw(column)} ${sql.raw(direction)}`)
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);