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:
SpecialX
2026-06-17 13:44:37 +08:00
parent 125f7ec54c
commit 3b6272c99d
195 changed files with 27274 additions and 416 deletions

275
src/app/api/search/route.ts Normal file
View 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) + "..."
}