feat: 完成 P1 全部功能 + 修复 proxy 导出 + 切换 MySQL 端口至 14013
## P1 功能(20 项) - 站内消息系统、家长仪表盘、学生考勤管理 - Excel 导入导出、用户批量导入、成绩导出 - 排课规则+自动排课+课表调整 - 成绩趋势+对比分析、密码安全策略、速率限制 - 数据变更日志、文件预览+存储策略、全文检索 - 依赖审计集成 CI、数据库定时备份、E2E 测试完善 - 通知偏好管理 ## 基础设施修复 - src/proxy.ts: 将 middleware 导出重命名为 proxy(Next.js 16 要求) - .env: MySQL 端口从 13002 切换至 14013 - scripts/create-db.ts: 新增数据库初始化脚本 ## 架构文档同步 - 004_architecture_impact_map.md 和 005_architecture_data.json 完整记录所有新增表、模块、路由、权限、依赖关系
This commit is contained in:
275
src/app/api/search/route.ts
Normal file
275
src/app/api/search/route.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { and, desc, eq, like, or, sql } from "drizzle-orm"
|
||||
|
||||
import { requireAuth } 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
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
await requireAuth()
|
||||
|
||||
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 (type === "all" || type === "question") {
|
||||
tasks.push(searchQuestions(kw, pageSize))
|
||||
}
|
||||
if (type === "all" || type === "textbook") {
|
||||
tasks.push(searchTextbooks(kw, pageSize))
|
||||
}
|
||||
if (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) + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user