Files
NextEdu/src/app/api/search/route.ts
SpecialX 1a9377222c feat(app): add error/loading boundaries and update dashboard routes
- Add error.tsx and loading.tsx boundaries for admin, parent, student, teacher routes

- Add dashboard-error-fallback and dashboard-loading-skeleton components

- Add student/learning page, parent/leave routes, teacher textbook components

- Update existing app routes across auth, dashboard, and API endpoints

- Update proxy middleware and next-auth type declarations
2026-06-23 17:38:28 +08:00

280 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<SearchResponse>({
success: true,
query: q,
type,
results: [],
total: 0,
page,
pageSize,
})
}
const kw = `%${q}%`
const offset = (page - 1) * pageSize
const results: SearchResultItem[] = []
// 并行查询各类型(按角色过滤)
const tasks: Promise<SearchResultItem[]>[] = []
// 学生不能搜索题目和考试
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<SearchResponse>({
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<SearchResultItem[]> {
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<SearchResultItem[]> {
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<SearchResultItem[]> {
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<SearchResultItem[]> {
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) + "..."
}