import { NextResponse } from "next/server" import { and, desc, eq, like, or, sql } from "drizzle-orm" import { getAuthContext } from "@/shared/lib/auth-guard" import { db } from "@/shared/db" import { announcements, exams, questions, textbooks, } from "@/shared/db/schema" export const dynamic = "force-dynamic" type SearchType = "all" | "question" | "textbook" | "exam" | "announcement" const isSearchType = (v: string): v is SearchType => v === "all" || v === "question" || v === "textbook" || v === "exam" || v === "announcement" const DEFAULT_PAGE_SIZE = 10 interface SearchResultItem { id: string title: string snippet: string type: "question" | "textbook" | "exam" | "announcement" href: string createdAt: string } interface SearchResponse { success: boolean query: string type: SearchType results: SearchResultItem[] total: number page: number pageSize: number } /** * GET /api/search?q=keyword&type=all&page=1 * 全文检索:questions / textbooks / exams / announcements * 按角色过滤:学生只能搜索 textbook 和 announcement */ export async function GET(req: Request) { try { const ctx = await getAuthContext() const isStudent = ctx.roles.includes("student") && !ctx.roles.includes("admin") && !ctx.roles.includes("teacher") const { searchParams } = new URL(req.url) const q = (searchParams.get("q") ?? "").trim() const typeRaw = (searchParams.get("type") ?? "all").trim() const type: SearchType = isSearchType(typeRaw) ? typeRaw : "all" const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1) const pageSize = Math.min( 50, Math.max(1, Number(searchParams.get("pageSize") ?? String(DEFAULT_PAGE_SIZE)) || DEFAULT_PAGE_SIZE) ) if (!q) { return NextResponse.json({ success: true, query: q, type, results: [], total: 0, page, pageSize, }) } const kw = `%${q}%` const offset = (page - 1) * pageSize const results: SearchResultItem[] = [] // 并行查询各类型(按角色过滤) const tasks: Promise[] = [] // 学生不能搜索题目和考试 if (!isStudent && (type === "all" || type === "question")) { tasks.push(searchQuestions(kw, pageSize)) } if (type === "all" || type === "textbook") { tasks.push(searchTextbooks(kw, pageSize)) } // 学生不能搜索考试 if (!isStudent && (type === "all" || type === "exam")) { tasks.push(searchExams(kw, pageSize)) } if (type === "all" || type === "announcement") { tasks.push(searchAnnouncements(kw, pageSize)) } const grouped = await Promise.all(tasks) for (const group of grouped) { results.push(...group) } // 按 createdAt 降序排序后分页 results.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) const total = results.length const paged = results.slice(offset, offset + pageSize) return NextResponse.json({ success: true, query: q, type, results: paged, total, page, pageSize, }) } catch (e) { const message = e instanceof Error ? e.message : "Search failed" const status = message.includes("auth_required") ? 401 : 500 return NextResponse.json({ success: false, message }, { status }) } } async function searchQuestions(kw: string, limit: number): Promise { try { const rows = await db .select({ id: questions.id, content: questions.content, type: questions.type, createdAt: questions.createdAt, }) .from(questions) .where( or( // JSON 内容字段转换为文本进行模糊匹配 sql`CAST(${questions.content} AS CHAR) LIKE ${kw}`, like(sql`CAST(${questions.content} AS CHAR)`, kw) )! ) .orderBy(desc(questions.createdAt)) .limit(limit) return rows.map((r) => { const text = extractTextFromJson(r.content) return { id: r.id, title: truncate(text, 80) || `Question (${r.type})`, snippet: truncate(text, 200), type: "question" as const, href: `/admin/questions?id=${r.id}`, createdAt: r.createdAt.toISOString(), } }) } catch { return [] } } async function searchTextbooks(kw: string, limit: number): Promise { try { const rows = await db .select({ id: textbooks.id, title: textbooks.title, subject: textbooks.subject, grade: textbooks.grade, publisher: textbooks.publisher, createdAt: textbooks.createdAt, }) .from(textbooks) .where( or( like(textbooks.title, kw), like(textbooks.subject, kw), like(textbooks.publisher, kw) )! ) .orderBy(desc(textbooks.createdAt)) .limit(limit) return rows.map((r) => ({ id: r.id, title: r.title, snippet: [r.subject, r.grade, r.publisher].filter(Boolean).join(" · "), type: "textbook" as const, href: `/admin/textbooks?id=${r.id}`, createdAt: r.createdAt.toISOString(), })) } catch { return [] } } async function searchExams(kw: string, limit: number): Promise { try { const rows = await db .select({ id: exams.id, title: exams.title, description: exams.description, status: exams.status, createdAt: exams.createdAt, }) .from(exams) .where( or( like(exams.title, kw), like(exams.description, kw) )! ) .orderBy(desc(exams.createdAt)) .limit(limit) return rows.map((r) => ({ id: r.id, title: r.title, snippet: r.description ?? `Status: ${r.status ?? "draft"}`, type: "exam" as const, href: `/admin/exams?id=${r.id}`, createdAt: r.createdAt.toISOString(), })) } catch { return [] } } async function searchAnnouncements(kw: string, limit: number): Promise { try { const rows = await db .select({ id: announcements.id, title: announcements.title, content: announcements.content, type: announcements.type, status: announcements.status, createdAt: announcements.createdAt, }) .from(announcements) .where( and( eq(announcements.status, "published"), or( like(announcements.title, kw), like(announcements.content, kw) )! ) ) .orderBy(desc(announcements.createdAt)) .limit(limit) return rows.map((r) => ({ id: r.id, title: r.title, snippet: truncate(stripHtml(r.content), 200), type: "announcement" as const, href: `/announcements/${r.id}`, createdAt: r.createdAt.toISOString(), })) } catch { return [] } } function extractTextFromJson(content: unknown): string { if (typeof content === "string") return content try { return JSON.stringify(content) } catch { return "" } } function stripHtml(html: string): string { return html.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim() } function truncate(s: string, max: number): string { const t = s.trim() if (t.length <= max) return t return t.slice(0, max) + "..." }