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

View File

@@ -0,0 +1,221 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Search, FileText, BookOpen, FileQuestion, Megaphone, Loader2 } from "lucide-react"
import { Input } from "@/shared/components/ui/input"
import { useDebounce } from "@/shared/hooks/use-debounce"
import { cn } from "@/shared/lib/utils"
type ResultType = "question" | "textbook" | "exam" | "announcement"
interface SearchResultItem {
id: string
title: string
snippet: string
type: ResultType
href: string
createdAt: string
}
interface SearchResponse {
success: boolean
results: SearchResultItem[]
total: number
query: string
}
const TYPE_ICON: Record<ResultType, React.ComponentType<{ className?: string }>> = {
question: FileQuestion,
textbook: BookOpen,
exam: FileText,
announcement: Megaphone,
}
const TYPE_LABEL: Record<ResultType, string> = {
question: "Question",
textbook: "Textbook",
exam: "Exam",
announcement: "Announcement",
}
interface GlobalSearchProps {
className?: string
placeholder?: string
}
export function GlobalSearch({
className,
placeholder = "Search... (Cmd+K)",
}: GlobalSearchProps) {
const router = useRouter()
const [query, setQuery] = React.useState("")
const [open, setOpen] = React.useState(false)
const [loading, setLoading] = React.useState(false)
const [results, setResults] = React.useState<SearchResultItem[]>([])
const [error, setError] = React.useState<string | null>(null)
const [activeIndex, setActiveIndex] = React.useState(0)
const debouncedQuery = useDebounce(query, 300)
const containerRef = React.useRef<HTMLDivElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
// Cmd/Ctrl + K 快捷键聚焦
React.useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault()
inputRef.current?.focus()
setOpen(true)
}
if (e.key === "Escape") {
setOpen(false)
inputRef.current?.blur()
}
}
window.addEventListener("keydown", handler)
return () => window.removeEventListener("keydown", handler)
}, [])
// 点击外部关闭
React.useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener("mousedown", handler)
return () => document.removeEventListener("mousedown", handler)
}, [])
// 防抖后发起搜索
React.useEffect(() => {
const q = debouncedQuery.trim()
if (!q) {
setResults([])
setError(null)
setLoading(false)
return
}
let cancelled = false
setLoading(true)
setError(null)
fetch(`/api/search?q=${encodeURIComponent(q)}&type=all&pageSize=20`)
.then((r) => r.json())
.then((data: SearchResponse) => {
if (cancelled) return
if (!data.success) {
setError("Search failed")
setResults([])
} else {
setResults(data.results)
setActiveIndex(0)
}
})
.catch(() => {
if (cancelled) return
setError("Network error")
setResults([])
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [debouncedQuery])
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault()
setActiveIndex((i) => Math.min(i + 1, results.length - 1))
} else if (e.key === "ArrowUp") {
e.preventDefault()
setActiveIndex((i) => Math.max(i - 1, 0))
} else if (e.key === "Enter") {
e.preventDefault()
const item = results[activeIndex]
if (item) {
setOpen(false)
router.push(item.href)
}
}
}
const showDropdown = open && query.trim().length > 0
return (
<div ref={containerRef} className={cn("relative", className)}>
<Search className="text-muted-foreground absolute top-2.5 left-2.5 size-4" />
<Input
ref={inputRef}
type="search"
placeholder={placeholder}
className="w-[200px] pl-9 lg:w-[300px]"
value={query}
onChange={(e) => {
setQuery(e.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
aria-label="Global search"
/>
{showDropdown ? (
<div className="absolute top-full right-0 z-50 mt-1 w-[min(480px,calc(100vw-2rem))] rounded-md border bg-popover p-0 shadow-md">
{loading ? (
<div className="flex items-center justify-center gap-2 px-4 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Searching...
</div>
) : error ? (
<div className="px-4 py-8 text-center text-sm text-destructive">{error}</div>
) : results.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No results found for &ldquo;{query}&rdquo;
</div>
) : (
<ul className="max-h-[60vh] overflow-auto py-1" role="listbox">
{results.map((item, idx) => {
const Icon = TYPE_ICON[item.type]
return (
<li key={`${item.type}-${item.id}`} role="option" aria-selected={idx === activeIndex}>
<Link
href={item.href}
onClick={() => {
setOpen(false)
setQuery("")
}}
className={cn(
"flex items-start gap-3 px-3 py-2 text-sm transition-colors hover:bg-accent",
idx === activeIndex && "bg-accent"
)}
onMouseEnter={() => setActiveIndex(idx)}
>
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate font-medium">{item.title}</p>
<span className="shrink-0 text-xs text-muted-foreground">
{TYPE_LABEL[item.type]}
</span>
</div>
{item.snippet ? (
<p className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">
{item.snippet}
</p>
) : null}
</div>
</Link>
</li>
)
})}
</ul>
)}
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/shared/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -1,35 +1,45 @@
import { relations } from "drizzle-orm";
import {
users,
import {
users,
accounts,
sessions,
roles,
usersToRoles,
questions,
knowledgePoints,
questionsToKnowledgePoints,
textbooks,
chapters,
roles,
usersToRoles,
questions,
knowledgePoints,
questionsToKnowledgePoints,
textbooks,
chapters,
schools,
grades,
classes,
classEnrollments,
classSchedule,
subjects,
exams,
examQuestions,
examSubmissions,
exams,
examQuestions,
examSubmissions,
submissionAnswers,
homeworkAssignments,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
homeworkSubmissions,
homeworkAnswers
homeworkAnswers,
announcements,
coursePlans,
coursePlanItems,
gradeRecords,
messages,
messageNotifications,
parentStudentRelations,
attendanceRecords,
attendanceRules,
passwordSecurity,
} from "./schema";
// --- Users & Roles Relations ---
export const usersRelations = relations(users, ({ many }) => ({
export const usersRelations = relations(users, ({ many, one }) => ({
accounts: many(accounts),
sessions: many(sessions),
usersToRoles: many(usersToRoles),
@@ -40,6 +50,11 @@ export const usersRelations = relations(users, ({ many }) => ({
submissions: many(examSubmissions),
homeworkSubmissions: many(homeworkSubmissions),
authoredQuestions: many(questions),
authoredAnnouncements: many(announcements),
sentMessages: many(messages, { relationName: "message_sender" }),
receivedMessages: many(messages, { relationName: "message_receiver" }),
notifications: many(messageNotifications),
passwordSecurity: one(passwordSecurity),
}));
export const accountsRelations = relations(accounts, ({ one }) => ({
@@ -314,3 +329,158 @@ export const homeworkAnswersRelations = relations(homeworkAnswers, ({ one }) =>
references: [questions.id],
}),
}));
// --- Announcements Relations ---
export const announcementsRelations = relations(announcements, ({ one }) => ({
author: one(users, {
fields: [announcements.authorId],
references: [users.id],
}),
targetGrade: one(grades, {
fields: [announcements.targetGradeId],
references: [grades.id],
}),
targetClass: one(classes, {
fields: [announcements.targetClassId],
references: [classes.id],
}),
}));
// --- Course Plans Relations ---
export const coursePlansRelations = relations(coursePlans, ({ one, many }) => ({
class: one(classes, {
fields: [coursePlans.classId],
references: [classes.id],
}),
subject: one(subjects, {
fields: [coursePlans.subjectId],
references: [subjects.id],
}),
teacher: one(users, {
fields: [coursePlans.teacherId],
references: [users.id],
relationName: "course_plan_teacher",
}),
createdByUser: one(users, {
fields: [coursePlans.createdBy],
references: [users.id],
relationName: "course_plan_creator",
}),
items: many(coursePlanItems),
}));
export const coursePlanItemsRelations = relations(coursePlanItems, ({ one }) => ({
plan: one(coursePlans, {
fields: [coursePlanItems.planId],
references: [coursePlans.id],
}),
}));
// --- Grade Records Relations ---
export const gradeRecordsRelations = relations(gradeRecords, ({ one }) => ({
student: one(users, {
fields: [gradeRecords.studentId],
references: [users.id],
relationName: "grade_records_student",
}),
class: one(classes, {
fields: [gradeRecords.classId],
references: [classes.id],
}),
subject: one(subjects, {
fields: [gradeRecords.subjectId],
references: [subjects.id],
}),
exam: one(exams, {
fields: [gradeRecords.examId],
references: [exams.id],
}),
recorder: one(users, {
fields: [gradeRecords.recordedBy],
references: [users.id],
relationName: "grade_records_recorder",
}),
}));
// --- Messages Relations ---
export const messagesRelations = relations(messages, ({ one, many }) => ({
sender: one(users, {
fields: [messages.senderId],
references: [users.id],
relationName: "message_sender",
}),
receiver: one(users, {
fields: [messages.receiverId],
references: [users.id],
relationName: "message_receiver",
}),
parent: one(messages, {
fields: [messages.parentMessageId],
references: [messages.id],
relationName: "message_thread",
}),
replies: many(messages, { relationName: "message_thread" }),
}));
// --- Message Notifications Relations ---
export const messageNotificationsRelations = relations(messageNotifications, ({ one }) => ({
user: one(users, {
fields: [messageNotifications.userId],
references: [users.id],
}),
}));
// --- Parent-Student Relations ---
export const parentStudentRelationsRelations = relations(parentStudentRelations, ({ one }) => ({
parent: one(users, {
fields: [parentStudentRelations.parentId],
references: [users.id],
relationName: "parent_relations",
}),
student: one(users, {
fields: [parentStudentRelations.studentId],
references: [users.id],
relationName: "student_relations",
}),
}));
// --- Attendance Relations ---
export const attendanceRecordsRelations = relations(attendanceRecords, ({ one }) => ({
student: one(users, {
fields: [attendanceRecords.studentId],
references: [users.id],
relationName: "attendance_records_student",
}),
class: one(classes, {
fields: [attendanceRecords.classId],
references: [classes.id],
}),
recorder: one(users, {
fields: [attendanceRecords.recordedBy],
references: [users.id],
relationName: "attendance_records_recorder",
}),
}));
export const attendanceRulesRelations = relations(attendanceRules, ({ one }) => ({
class: one(classes, {
fields: [attendanceRules.classId],
references: [classes.id],
}),
}));
// --- Password Security Relations ---
export const passwordSecurityRelations = relations(passwordSecurity, ({ one }) => ({
user: one(users, {
fields: [passwordSecurity.userId],
references: [users.id],
}),
}));

View File

@@ -9,7 +9,11 @@ import {
json,
mysqlEnum,
boolean,
foreignKey
foreignKey,
date,
datetime,
decimal,
bigint
} from "drizzle-orm/mysql-core";
import { createId } from "@paralleldrive/cuid2";
import type { AdapterAccountType } from "next-auth/adapters";
@@ -35,6 +39,13 @@ export const users = mysqlTable("users", {
gradeId: varchar("grade_id", { length: 128 }),
departmentId: varchar("department_id", { length: 128 }),
onboardedAt: timestamp("onboarded_at", { mode: "date" }),
// 未成年人信息保护
birthDate: date("birth_date"),
guardianName: varchar("guardian_name", { length: 255 }),
guardianPhone: varchar("guardian_phone", { length: 20 }),
guardianRelation: varchar("guardian_relation", { length: 50 }),
consentAcceptedAt: datetime("consent_accepted_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
@@ -623,7 +634,452 @@ export const aiProviders = mysqlTable("ai_providers", {
defaultIdx: index("ai_provider_default_idx").on(table.isDefault),
}));
// Re-export old courses table if needed or deprecate it.
// --- 7. Announcements ---
export const announcementTypeEnum = mysqlEnum("type", ["school", "grade", "class"]);
export const announcementStatusEnum = mysqlEnum("status", ["draft", "published", "archived"]);
export const announcements = mysqlTable("announcements", {
id: id("id").primaryKey(),
title: varchar("title", { length: 255 }).notNull(),
content: text("content").notNull(),
type: announcementTypeEnum.default("school").notNull(),
status: announcementStatusEnum.default("draft").notNull(),
targetGradeId: varchar("target_grade_id", { length: 128 }),
targetClassId: varchar("target_class_id", { length: 128 }),
authorId: varchar("author_id", { length: 128 }).notNull().references(() => users.id),
publishedAt: datetime("published_at", { mode: "date" }),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(),
}, (table) => ({
authorIdx: index("announcements_author_idx").on(table.authorId),
statusIdx: index("announcements_status_idx").on(table.status),
typeIdx: index("announcements_type_idx").on(table.type),
targetGradeIdx: index("announcements_target_grade_idx").on(table.targetGradeId),
targetClassIdx: index("announcements_target_class_idx").on(table.targetClassId),
}));
// --- 8. Audit & Login Logs ---
export const auditLogStatusEnum = mysqlEnum("status", ["success", "failure"]);
export const auditLogs = mysqlTable("audit_logs", {
id: id("id").primaryKey(),
userId: varchar("user_id", { length: 128 }).notNull(),
userName: varchar("user_name", { length: 255 }).notNull(),
action: varchar("action", { length: 255 }).notNull(),
module: varchar("module", { length: 128 }).notNull(),
targetId: varchar("target_id", { length: 128 }),
targetType: varchar("target_type", { length: 128 }),
detail: text("detail"),
ipAddress: varchar("ip_address", { length: 45 }),
userAgent: varchar("user_agent", { length: 512 }),
status: auditLogStatusEnum.default("success").notNull(),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
}, (table) => ({
userIdIdx: index("audit_logs_user_id_idx").on(table.userId),
moduleIdx: index("audit_logs_module_idx").on(table.module),
actionIdx: index("audit_logs_action_idx").on(table.action),
statusIdx: index("audit_logs_status_idx").on(table.status),
createdAtIdx: index("audit_logs_created_at_idx").on(table.createdAt),
}));
export const loginLogActionEnum = mysqlEnum("action", ["signin", "signout", "signup"]);
export const loginLogStatusEnum = mysqlEnum("status", ["success", "failure"]);
export const loginLogs = mysqlTable("login_logs", {
id: id("id").primaryKey(),
userId: varchar("user_id", { length: 128 }),
userEmail: varchar("user_email", { length: 255 }).notNull(),
action: loginLogActionEnum.notNull(),
status: loginLogStatusEnum.default("success").notNull(),
ipAddress: varchar("ip_address", { length: 45 }),
userAgent: varchar("user_agent", { length: 512 }),
errorMessage: text("error_message"),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
}, (table) => ({
userIdIdx: index("login_logs_user_id_idx").on(table.userId),
userEmailIdx: index("login_logs_user_email_idx").on(table.userEmail),
actionIdx: index("login_logs_action_idx").on(table.action),
statusIdx: index("login_logs_status_idx").on(table.status),
createdAtIdx: index("login_logs_created_at_idx").on(table.createdAt),
}));
// --- 8b. Data Change Logs (数据变更日志) ---
export const dataChangeLogActionEnum = mysqlEnum("action", ["create", "update", "delete"]);
export const dataChangeLogs = mysqlTable("data_change_logs", {
id: varchar("id", { length: 128 }).primaryKey(),
tableName: varchar("table_name", { length: 128 }).notNull(),
recordId: varchar("record_id", { length: 128 }).notNull(),
action: dataChangeLogActionEnum.notNull(),
oldValue: text("old_value"),
newValue: text("new_value"),
changedBy: varchar("changed_by", { length: 128 }).notNull(),
changedByName: varchar("changed_by_name", { length: 255 }).notNull(),
ipAddress: varchar("ip_address", { length: 45 }),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
}, (table) => ({
tableNameIdx: index("data_change_logs_table_name_idx").on(table.tableName),
recordIdIdx: index("data_change_logs_record_id_idx").on(table.recordId),
actionIdx: index("data_change_logs_action_idx").on(table.action),
changedByIdx: index("data_change_logs_changed_by_idx").on(table.changedBy),
createdAtIdx: index("data_change_logs_created_at_idx").on(table.createdAt),
}));
// Re-export old courses table if needed or deprecate it.
// Assuming we are replacing the old simple schema with this robust one.
// But if there were existing tables, we might keep them or comment them out.
// For this task, I will overwrite completely as this is a "System Architect" redesign.
// --- 9. Grade Records (成绩录入) ---
export const gradeRecordTypeEnum = mysqlEnum("type", ["exam", "quiz", "homework", "other"]);
export const gradeRecordSemesterEnum = mysqlEnum("semester", ["1", "2"]);
export const gradeRecords = mysqlTable("grade_records", {
id: id("id").primaryKey(),
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }),
subjectId: varchar("subject_id", { length: 128 }).notNull().references(() => subjects.id, { onDelete: "cascade" }),
examId: varchar("exam_id", { length: 128 }),
academicYearId: varchar("academic_year_id", { length: 128 }),
title: varchar("title", { length: 255 }).notNull(),
score: decimal("score", { precision: 6, scale: 2 }).notNull(),
fullScore: decimal("full_score", { precision: 6, scale: 2 }).default("100").notNull(),
type: gradeRecordTypeEnum.default("exam").notNull(),
semester: gradeRecordSemesterEnum.default("1").notNull(),
recordedBy: varchar("recorded_by", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
remark: text("remark"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
studentIdx: index("grade_records_student_idx").on(table.studentId),
classIdx: index("grade_records_class_idx").on(table.classId),
subjectIdx: index("grade_records_subject_idx").on(table.subjectId),
examIdx: index("grade_records_exam_idx").on(table.examId),
classSubjectIdx: index("grade_records_class_subject_idx").on(table.classId, table.subjectId),
recordedByIdx: index("grade_records_recorded_by_idx").on(table.recordedBy),
classFk: foreignKey({
columns: [table.classId],
foreignColumns: [classes.id],
name: "gr_c_fk",
}).onDelete("cascade"),
studentFk: foreignKey({
columns: [table.studentId],
foreignColumns: [users.id],
name: "gr_s_fk",
}).onDelete("cascade"),
subjectFk: foreignKey({
columns: [table.subjectId],
foreignColumns: [subjects.id],
name: "gr_sub_fk",
}).onDelete("cascade"),
recordedByFk: foreignKey({
columns: [table.recordedBy],
foreignColumns: [users.id],
name: "gr_rb_fk",
}).onDelete("cascade"),
}));
// --- 10. File Attachments (文件附件) ---
export const fileAttachments = mysqlTable("file_attachments", {
id: varchar("id", { length: 128 }).primaryKey(),
filename: varchar("filename", { length: 255 }).notNull(),
originalName: varchar("original_name", { length: 255 }).notNull(),
mimeType: varchar("mime_type", { length: 128 }).notNull(),
size: bigint("size", { mode: "number" }).notNull(),
storagePath: varchar("storage_path", { length: 512 }).notNull(),
url: varchar("url", { length: 512 }),
uploaderId: varchar("uploader_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
targetType: varchar("target_type", { length: 128 }),
targetId: varchar("target_id", { length: 128 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => ({
uploaderIdx: index("file_attachments_uploader_idx").on(table.uploaderId),
targetIdx: index("file_attachments_target_idx").on(table.targetType, table.targetId),
createdAtIdx: index("file_attachments_created_at_idx").on(table.createdAt),
}));
// --- 11. Course Plans (课程计划) ---
export const coursePlanStatusEnum = mysqlEnum("status", ["planning", "active", "completed", "paused"]);
export const coursePlanSemesterEnum = mysqlEnum("semester", ["1", "2"]);
export const coursePlans = mysqlTable("course_plans", {
id: id("id").primaryKey(),
classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }),
subjectId: varchar("subject_id", { length: 128 }).notNull().references(() => subjects.id, { onDelete: "cascade" }),
teacherId: varchar("teacher_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
academicYearId: varchar("academic_year_id", { length: 128 }),
semester: coursePlanSemesterEnum.default("1").notNull(),
totalHours: int("total_hours").default(0).notNull(),
completedHours: int("completed_hours").default(0).notNull(),
weeklyHours: int("weekly_hours").default(0).notNull(),
startDate: date("start_date"),
endDate: date("end_date"),
syllabus: text("syllabus"),
objectives: text("objectives"),
status: coursePlanStatusEnum.default("planning").notNull(),
createdBy: varchar("created_by", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
classIdx: index("course_plans_class_idx").on(table.classId),
teacherIdx: index("course_plans_teacher_idx").on(table.teacherId),
subjectIdx: index("course_plans_subject_idx").on(table.subjectId),
statusIdx: index("course_plans_status_idx").on(table.status),
classSubjectIdx: index("course_plans_class_subject_idx").on(table.classId, table.subjectId),
classFk: foreignKey({
columns: [table.classId],
foreignColumns: [classes.id],
name: "cp_c_fk",
}).onDelete("cascade"),
subjectFk: foreignKey({
columns: [table.subjectId],
foreignColumns: [subjects.id],
name: "cp_s_fk",
}).onDelete("cascade"),
teacherFk: foreignKey({
columns: [table.teacherId],
foreignColumns: [users.id],
name: "cp_t_fk",
}).onDelete("cascade"),
createdByFk: foreignKey({
columns: [table.createdBy],
foreignColumns: [users.id],
name: "cp_cb_fk",
}).onDelete("cascade"),
}));
export const coursePlanItems = mysqlTable("course_plan_items", {
id: id("id").primaryKey(),
planId: varchar("plan_id", { length: 128 }).notNull().references(() => coursePlans.id, { onDelete: "cascade" }),
week: int("week").notNull(),
topic: varchar("topic", { length: 255 }).notNull(),
content: text("content"),
hours: int("hours").default(2).notNull(),
textbookChapter: varchar("textbook_chapter", { length: 255 }),
notes: text("notes"),
isCompleted: boolean("is_completed").default(false).notNull(),
completedAt: date("completed_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
planIdx: index("course_plan_items_plan_idx").on(table.planId),
planWeekIdx: index("course_plan_items_plan_week_idx").on(table.planId, table.week),
planFk: foreignKey({
columns: [table.planId],
foreignColumns: [coursePlans.id],
name: "cpi_p_fk",
}).onDelete("cascade"),
}));
// --- 13. Messages (站内消息) ---
export const messages = mysqlTable("messages", {
id: id("id").primaryKey(),
senderId: varchar("sender_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
receiverId: varchar("receiver_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
subject: varchar("subject", { length: 255 }),
content: text("content").notNull(),
isRead: boolean("is_read").default(false).notNull(),
readAt: timestamp("read_at", { mode: "date" }),
parentMessageId: varchar("parent_message_id", { length: 128 }), // 回复链
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
}, (table) => ({
senderIdx: index("messages_sender_idx").on(table.senderId),
receiverIdx: index("messages_receiver_idx").on(table.receiverId),
isReadIdx: index("messages_is_read_idx").on(table.isRead),
parentIdx: index("messages_parent_idx").on(table.parentMessageId),
receiverReadIdx: index("messages_receiver_read_idx").on(table.receiverId, table.isRead),
}));
// --- 14. Message Notifications (消息通知) ---
export const messageNotifications = mysqlTable("message_notifications", {
id: id("id").primaryKey(),
userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
type: varchar("type", { length: 128 }).notNull(), // "message", "announcement", "homework", "grade"
title: varchar("title", { length: 255 }).notNull(),
content: text("content"),
link: varchar("link", { length: 512 }),
isRead: boolean("is_read").default(false).notNull(),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
}, (table) => ({
userIdx: index("message_notifications_user_idx").on(table.userId),
isReadIdx: index("message_notifications_is_read_idx").on(table.isRead),
userReadIdx: index("message_notifications_user_read_idx").on(table.userId, table.isRead),
createdAtIdx: index("message_notifications_created_at_idx").on(table.createdAt),
}));
// --- 14b. Notification Preferences (通知偏好) ---
export const notificationPreferences = mysqlTable("notification_preferences", {
id: varchar("id", { length: 128 }).primaryKey(),
userId: varchar("user_id", { length: 128 }).notNull().unique().references(() => users.id, { onDelete: "cascade" }),
emailEnabled: boolean("email_enabled").default(false).notNull(),
smsEnabled: boolean("sms_enabled").default(false).notNull(),
pushEnabled: boolean("push_enabled").default(true).notNull(),
homeworkNotifications: boolean("homework_notifications").default(true).notNull(),
gradeNotifications: boolean("grade_notifications").default(true).notNull(),
announcementNotifications: boolean("announcement_notifications").default(true).notNull(),
messageNotifications: boolean("message_notifications").default(true).notNull(),
attendanceNotifications: boolean("attendance_notifications").default(true).notNull(),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(),
}, (table) => ({
userIdIdx: index("notification_preferences_user_idx").on(table.userId),
userFk: foreignKey({
columns: [table.userId],
foreignColumns: [users.id],
name: "np_u_fk",
}).onDelete("cascade"),
}));
// --- 12. Parent-Student Relations (家长-子女关联) ---
export const parentStudentRelations = mysqlTable("parent_student_relations", {
id: varchar("id", { length: 128 }).primaryKey().$defaultFn(() => createId()),
parentId: varchar("parent_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
relation: varchar("relation", { length: 50 }), // 父亲/母亲/其他
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => ({
parentIdx: index("parent_student_relations_parent_idx").on(table.parentId),
studentIdx: index("parent_student_relations_student_idx").on(table.studentId),
parentFk: foreignKey({
columns: [table.parentId],
foreignColumns: [users.id],
name: "psr_p_fk",
}).onDelete("cascade"),
studentFk: foreignKey({
columns: [table.studentId],
foreignColumns: [users.id],
name: "psr_s_fk",
}).onDelete("cascade"),
}));
// --- 15. Attendance (考勤管理) ---
export const attendanceStatusEnum = mysqlEnum("status", ["present", "absent", "late", "early_leave", "excused"]);
export const attendanceRecords = mysqlTable("attendance_records", {
id: id("id").primaryKey(),
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }),
scheduleId: varchar("schedule_id", { length: 128 }),
date: date("date").notNull(),
status: attendanceStatusEnum.notNull(),
remark: text("remark"),
recordedBy: varchar("recorded_by", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
studentIdx: index("attendance_records_student_idx").on(table.studentId),
classIdx: index("attendance_records_class_idx").on(table.classId),
dateIdx: index("attendance_records_date_idx").on(table.date),
classDateIdx: index("attendance_records_class_date_idx").on(table.classId, table.date),
studentDateIdx: index("attendance_records_student_date_idx").on(table.studentId, table.date),
scheduleIdx: index("attendance_records_schedule_idx").on(table.scheduleId),
recordedByIdx: index("attendance_records_recorded_by_idx").on(table.recordedBy),
classFk: foreignKey({
columns: [table.classId],
foreignColumns: [classes.id],
name: "ar_c_fk",
}).onDelete("cascade"),
studentFk: foreignKey({
columns: [table.studentId],
foreignColumns: [users.id],
name: "ar_s_fk",
}).onDelete("cascade"),
recordedByFk: foreignKey({
columns: [table.recordedBy],
foreignColumns: [users.id],
name: "ar_rb_fk",
}).onDelete("cascade"),
}));
export const attendanceRules = mysqlTable("attendance_rules", {
id: id("id").primaryKey(),
classId: varchar("class_id", { length: 128 }).references(() => classes.id, { onDelete: "cascade" }),
lateThresholdMinutes: int("late_threshold_minutes").default(15),
earlyLeaveThresholdMinutes: int("early_leave_threshold_minutes").default(15),
enableAutoMark: boolean("enable_auto_mark").default(false),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
classIdx: index("attendance_rules_class_idx").on(table.classId),
classFk: foreignKey({
columns: [table.classId],
foreignColumns: [classes.id],
name: "atr_c_fk",
}).onDelete("cascade"),
}));
// --- 17. Password Security (密码安全策略) ---
export const passwordSecurity = mysqlTable("password_security", {
id: varchar("id", { length: 128 }).primaryKey().$defaultFn(() => createId()),
userId: varchar("user_id", { length: 128 }).notNull().unique().references(() => users.id, { onDelete: "cascade" }),
failedLoginAttempts: int("failed_login_attempts").default(0).notNull(),
lockedUntil: timestamp("locked_until", { mode: "date" }),
passwordChangedAt: timestamp("password_changed_at").defaultNow().notNull(),
mustChangePassword: boolean("must_change_password").default(false).notNull(),
lastPasswordChange: timestamp("last_password_change", { mode: "date" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
userIdIdx: index("password_security_user_idx").on(table.userId),
userFk: foreignKey({
columns: [table.userId],
foreignColumns: [users.id],
name: "ps_u_fk",
}).onDelete("cascade"),
}));
// --- 16. Scheduling Rules & Schedule Changes (排课规则与调课) ---
export const schedulingRules = mysqlTable("scheduling_rules", {
id: id("id").primaryKey(),
classId: varchar("class_id", { length: 128 }), // null=全局规则
maxDailyHours: int("max_daily_hours").default(8),
maxContinuousHours: int("max_continuous_hours").default(2),
lunchBreakStart: varchar("lunch_break_start", { length: 10 }).default("12:00"),
lunchBreakEnd: varchar("lunch_break_end", { length: 10 }).default("13:00"),
morningStart: varchar("morning_start", { length: 10 }).default("08:00"),
afternoonEnd: varchar("afternoon_end", { length: 10 }).default("17:00"),
avoidBackToBack: boolean("avoid_back_to_back").default(false),
balancedSubjects: boolean("balanced_subjects").default(true),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
classIdx: index("scheduling_rules_class_idx").on(table.classId),
}));
export const scheduleChangeStatusEnum = mysqlEnum("status", ["pending", "approved", "rejected", "completed"]);
export const scheduleChanges = mysqlTable("schedule_changes", {
id: id("id").primaryKey(),
originalScheduleId: varchar("original_schedule_id", { length: 128 }),
classId: varchar("class_id", { length: 128 }).notNull(),
originalTeacherId: varchar("original_teacher_id", { length: 128 }),
substituteTeacherId: varchar("substitute_teacher_id", { length: 128 }),
originalDate: date("original_date"),
newDate: date("new_date"),
newStartTime: varchar("new_start_time", { length: 10 }),
newEndTime: varchar("new_end_time", { length: 10 }),
reason: text("reason"),
status: scheduleChangeStatusEnum.default("pending").notNull(),
requestedBy: varchar("requested_by", { length: 128 }).notNull(),
approvedBy: varchar("approved_by", { length: 128 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
classIdx: index("schedule_changes_class_idx").on(table.classId),
statusIdx: index("schedule_changes_status_idx").on(table.status),
requestedByIdx: index("schedule_changes_requested_by_idx").on(table.requestedBy),
originalScheduleIdx: index("schedule_changes_original_schedule_idx").on(table.originalScheduleId),
}));

View File

@@ -0,0 +1,50 @@
"use server"
import { createId } from "@paralleldrive/cuid2"
import { headers } from "next/headers"
import { db } from "@/shared/db"
import { auditLogs } from "@/shared/db/schema"
import { auth } from "@/auth"
export type AuditLogStatus = "success" | "failure"
export interface LogAuditParams {
action: string
module: string
targetId?: string
targetType?: string
detail?: Record<string, unknown>
status?: AuditLogStatus
}
/**
* Record an audit log entry for the current authenticated user.
* Silently fails on error so it never breaks the main operation.
*/
export async function logAudit(params: LogAuditParams): Promise<void> {
try {
const session = await auth()
const headerList = await headers()
const ipAddress =
headerList.get("x-forwarded-for") ??
headerList.get("x-real-ip") ??
"unknown"
const userAgent = headerList.get("user-agent") ?? "unknown"
await db.insert(auditLogs).values({
id: createId(),
userId: session?.user?.id ?? "unknown",
userName: session?.user?.name ?? "unknown",
action: params.action,
module: params.module,
targetId: params.targetId ?? null,
targetType: params.targetType ?? null,
detail: params.detail ? JSON.stringify(params.detail) : null,
ipAddress,
userAgent,
status: params.status ?? "success",
})
} catch {
// Silently fail - logging should not break the main operation
}
}

View File

@@ -5,6 +5,7 @@ import {
classes,
classSubjectTeachers,
grades,
parentStudentRelations,
} from "@/shared/db/schema"
import { eq, or } from "drizzle-orm"
@@ -116,8 +117,12 @@ async function resolveDataScope(userId: string, roleNames: string[]): Promise<Da
// Parent: can see their children's data
if (roleNames.includes("parent")) {
// TODO: implement parent-child relationship lookup
return { type: "children", childrenIds: [] }
const children = await db
.select({ studentId: parentStudentRelations.studentId })
.from(parentStudentRelations)
.where(eq(parentStudentRelations.parentId, userId))
return { type: "children", childrenIds: children.map((c) => c.studentId) }
}
// Fallback: only own data

View File

@@ -0,0 +1,47 @@
"use server"
import { createId } from "@paralleldrive/cuid2"
import { headers } from "next/headers"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { dataChangeLogs } from "@/shared/db/schema"
export type DataChangeAction = "create" | "update" | "delete"
export interface LogDataChangeParams {
tableName: string
recordId: string
action: DataChangeAction
oldValue?: Record<string, unknown>
newValue?: Record<string, unknown>
}
/**
* Record a data change log entry for the current authenticated user.
* Silently fails on error so it never blocks the main operation.
*/
export async function logDataChange(params: LogDataChangeParams): Promise<void> {
try {
const session = await auth()
const headerList = await headers()
const ipAddress =
headerList.get("x-forwarded-for") ??
headerList.get("x-real-ip") ??
"unknown"
await db.insert(dataChangeLogs).values({
id: createId(),
tableName: params.tableName,
recordId: params.recordId,
action: params.action,
oldValue: params.oldValue ? JSON.stringify(params.oldValue) : null,
newValue: params.newValue ? JSON.stringify(params.newValue) : null,
changedBy: session?.user?.id ?? "unknown",
changedByName: session?.user?.name ?? "unknown",
ipAddress,
})
} catch {
// Silently fail - change logging must not break the main operation
}
}

173
src/shared/lib/excel.ts Normal file
View File

@@ -0,0 +1,173 @@
import "server-only"
import ExcelJS from "exceljs"
export type ExcelColumn = {
header: string
key: string
width?: number
}
export type ExcelSheet = {
name: string
columns: ExcelColumn[]
rows: Record<string, unknown>[]
}
export type TemplateColumn = ExcelColumn & {
note?: string
}
export type TemplateSheet = {
name: string
columns: TemplateColumn[]
sampleRows?: Record<string, unknown>[]
}
export type ParsedSheet = {
sheetName: string
rows: Record<string, unknown>[]
}
/**
* 导出数据到 Excel Buffer
*/
export async function exportToExcel(params: {
sheets: ExcelSheet[]
}): Promise<Buffer> {
const workbook = new ExcelJS.Workbook()
for (const sheet of params.sheets) {
const worksheet = workbook.addWorksheet(sheet.name, {
views: [{ state: "frozen", ySplit: 1 }],
})
worksheet.columns = sheet.columns.map((col) => ({
header: col.header,
key: col.key,
width: col.width ?? 20,
}))
// Header style
const headerRow = worksheet.getRow(1)
headerRow.font = { bold: true }
headerRow.fill = {
type: "pattern",
pattern: "solid",
fgColor: { argb: "FFE0E7FF" },
}
headerRow.alignment = { vertical: "middle", horizontal: "left" }
for (const row of sheet.rows) {
worksheet.addRow(row)
}
worksheet.autoFilter = {
from: { row: 1, column: 1 },
to: { row: 1, column: sheet.columns.length },
}
}
const arrayBuffer = await workbook.xlsx.writeBuffer()
return Buffer.from(arrayBuffer)
}
/**
* 从 Buffer 解析 Excel
*/
export async function parseExcel(buffer: Buffer): Promise<ParsedSheet[]> {
const workbook = new ExcelJS.Workbook()
await workbook.xlsx.load(buffer as unknown as ArrayBuffer)
const result: ParsedSheet[] = []
workbook.worksheets.forEach((worksheet) => {
if (worksheet.rowCount === 0) {
result.push({ sheetName: worksheet.name, rows: [] })
return
}
const headerRow = worksheet.getRow(1)
const headers: string[] = []
const keys: string[] = []
headerRow.eachCell((cell, colNumber) => {
const header = String(cell.value ?? "").trim()
headers.push(header)
keys.push(header || `column_${colNumber}`)
})
const rows: Record<string, unknown>[] = []
for (let rowNum = 2; rowNum <= worksheet.rowCount; rowNum++) {
const row = worksheet.getRow(rowNum)
const record: Record<string, unknown> = {}
let hasValue = false
keys.forEach((key, idx) => {
const cell = row.getCell(idx + 1)
const value = cell.value
if (value !== null && value !== undefined && value !== "") {
record[key] = typeof value === "object" && "text" in value
? String((value as { text: string }).text)
: value
hasValue = true
} else {
record[key] = ""
}
})
if (hasValue) rows.push(record)
}
result.push({ sheetName: worksheet.name, rows })
})
return result
}
/**
* 生成导入模板 Buffer
*/
export async function generateTemplate(params: {
sheets: TemplateSheet[]
}): Promise<Buffer> {
const workbook = new ExcelJS.Workbook()
for (const sheet of params.sheets) {
const worksheet = workbook.addWorksheet(sheet.name)
worksheet.columns = sheet.columns.map((col) => ({
header: col.header,
key: col.key,
width: col.width ?? 22,
}))
const headerRow = worksheet.getRow(1)
headerRow.font = { bold: true }
headerRow.fill = {
type: "pattern",
pattern: "solid",
fgColor: { argb: "FFE0E7FF" },
}
// Notes row (row 2)
const noteRow = worksheet.getRow(2)
sheet.columns.forEach((col, idx) => {
const cell = noteRow.getCell(idx + 1)
cell.value = col.note ?? ""
cell.font = { italic: true, color: { argb: "FF6B7280" } }
})
// Sample rows (starting from row 3)
const sampleRows = sheet.sampleRows ?? []
sampleRows.forEach((row) => worksheet.addRow(row))
// Empty row for user input
if (sampleRows.length === 0) {
worksheet.addRow({})
}
}
const arrayBuffer = await workbook.xlsx.writeBuffer()
return Buffer.from(arrayBuffer)
}

View File

@@ -0,0 +1,70 @@
import { createId } from "@paralleldrive/cuid2"
// 允许的 MIME 类型图片、PDF、Office 文档、文本、压缩包
export const ALLOWED_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"text/markdown",
"application/zip",
"application/x-rar-compressed",
] as const
// 最大文件大小10MB
export const MAX_FILE_SIZE = 10 * 1024 * 1024
/**
* 判断 MIME 类型是否在允许列表中
*/
export function isAllowedMimeType(mimeType: string): boolean {
return (ALLOWED_MIME_TYPES as readonly string[]).includes(mimeType)
}
/**
* 从文件名中提取扩展名(小写,不含点)
*/
export function getFileExtension(filename: string): string {
const idx = filename.lastIndexOf(".")
if (idx <= 0 || idx === filename.length - 1) return ""
return filename.slice(idx + 1).toLowerCase()
}
/**
* 根据原始文件名生成存储路径uploads/YYYY-MM/cuid.ext
* 路径相对于 public/ 目录
*/
export function generateStoragePath(originalName: string): string {
const ext = getFileExtension(originalName)
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, "0")
const monthDir = `${year}-${month}`
const id = createId()
const filename = ext ? `${id}.${ext}` : id
return `uploads/${monthDir}/${filename}`
}
/**
* 将字节数格式化为人类可读的字符串
*/
export function formatFileSize(bytes: number): string {
if (bytes <= 0) return "0 B"
const units = ["B", "KB", "MB", "GB", "TB"]
const i = Math.min(
units.length - 1,
Math.floor(Math.log(bytes) / Math.log(1024))
)
const value = bytes / Math.pow(1024, i)
const rounded = i === 0 ? value.toString() : value.toFixed(1)
return `${rounded} ${units[i]}`
}

View File

@@ -0,0 +1,46 @@
"use server"
import { createId } from "@paralleldrive/cuid2"
import { headers } from "next/headers"
import { db } from "@/shared/db"
import { loginLogs } from "@/shared/db/schema"
export type LoginLogAction = "signin" | "signout" | "signup"
export type LoginLogStatus = "success" | "failure"
export interface LogLoginEventParams {
userId?: string
userEmail: string
action: LoginLogAction
status?: LoginLogStatus
errorMessage?: string
}
/**
* Record a login-related log entry (signin/signout/signup).
* Does NOT depend on auth context since it runs during auth events.
* Silently fails on error so it never breaks the auth flow.
*/
export async function logLoginEvent(params: LogLoginEventParams): Promise<void> {
try {
const headerList = await headers()
const ipAddress =
headerList.get("x-forwarded-for") ??
headerList.get("x-real-ip") ??
"unknown"
const userAgent = headerList.get("user-agent") ?? "unknown"
await db.insert(loginLogs).values({
id: createId(),
userId: params.userId ?? null,
userEmail: params.userEmail,
action: params.action,
status: params.status ?? "success",
ipAddress,
userAgent,
errorMessage: params.errorMessage ?? null,
})
} catch {
// Silently fail - logging should not break the auth flow
}
}

View File

@@ -0,0 +1,119 @@
/**
* Password security policy and account lockout helpers.
*
* These utilities are pure (no DB / I/O) so they can be safely used
* in both server and client contexts.
*/
export const PASSWORD_RULES = {
minLength: 8,
requireUppercase: true,
requireLowercase: true,
requireNumber: true,
requireSpecialChar: false,
maxLoginAttempts: 5,
lockoutDurationMinutes: 30,
} as const
export interface PasswordValidationResult {
valid: boolean
errors: string[]
}
/**
* Validate a password against the configured policy.
*/
export function validatePassword(password: string): PasswordValidationResult {
const errors: string[] = []
if (password.length < PASSWORD_RULES.minLength) {
errors.push(`Password must be at least ${PASSWORD_RULES.minLength} characters long`)
}
if (PASSWORD_RULES.requireUppercase && !/[A-Z]/.test(password)) {
errors.push("Password must contain at least one uppercase letter")
}
if (PASSWORD_RULES.requireLowercase && !/[a-z]/.test(password)) {
errors.push("Password must contain at least one lowercase letter")
}
if (PASSWORD_RULES.requireNumber && !/[0-9]/.test(password)) {
errors.push("Password must contain at least one number")
}
if (PASSWORD_RULES.requireSpecialChar && !/[^A-Za-z0-9]/.test(password)) {
errors.push("Password must contain at least one special character")
}
return { valid: errors.length === 0, errors }
}
export type PasswordStrength = "weak" | "medium" | "strong"
/**
* Compute a coarse password strength label based on length and character
* diversity. Useful for client-side strength indicators.
*/
export function getPasswordStrength(password: string): PasswordStrength {
if (password.length === 0) return "weak"
let score = 0
if (password.length >= 8) score++
if (password.length >= 12) score++
if (/[a-z]/.test(password)) score++
if (/[A-Z]/.test(password)) score++
if (/[0-9]/.test(password)) score++
if (/[^A-Za-z0-9]/.test(password)) score++
if (score <= 2) return "weak"
if (score <= 4) return "medium"
return "strong"
}
/**
* Determine whether an account should be considered locked given its
* failed-attempt count and the timestamp of the most recent failure.
*
* The lockout is lifted automatically once `lockoutDurationMinutes` have
* elapsed since `lastFailedAt`.
*/
export function isAccountLocked(
failedAttempts: number,
lastFailedAt: Date | null
): boolean {
if (failedAttempts < PASSWORD_RULES.maxLoginAttempts) return false
if (!lastFailedAt) return false
const lockoutMs = PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000
const elapsed = Date.now() - lastFailedAt.getTime()
return elapsed < lockoutMs
}
/**
* Compute the remaining lockout time in milliseconds (0 if unlocked).
*/
export function getRemainingLockoutMs(
failedAttempts: number,
lastFailedAt: Date | null
): number {
if (!isAccountLocked(failedAttempts, lastFailedAt)) return 0
if (!lastFailedAt) return 0
const lockoutMs = PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000
const elapsed = Date.now() - lastFailedAt.getTime()
return Math.max(0, lockoutMs - elapsed)
}
/**
* Human-readable summary of the password policy. Used by the UI to
* display requirements next to the password input.
*/
export const PASSWORD_REQUIREMENT_HINTS: string[] = [
`At least ${PASSWORD_RULES.minLength} characters`,
PASSWORD_RULES.requireUppercase ? "At least one uppercase letter (A-Z)" : null,
PASSWORD_RULES.requireLowercase ? "At least one lowercase letter (a-z)" : null,
PASSWORD_RULES.requireNumber ? "At least one number (0-9)" : null,
PASSWORD_RULES.requireSpecialChar
? "At least one special character (!@#$...)"
: null,
].filter((s): s is string => Boolean(s))

View File

@@ -33,6 +33,20 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.AI_CHAT,
Permissions.AI_CONFIGURE,
Permissions.SETTINGS_ADMIN,
Permissions.AUDIT_LOG_READ,
Permissions.ANNOUNCEMENT_MANAGE,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_MANAGE,
Permissions.GRADE_RECORD_READ,
Permissions.COURSE_PLAN_MANAGE,
Permissions.COURSE_PLAN_READ,
Permissions.ATTENDANCE_MANAGE,
Permissions.ATTENDANCE_READ,
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
Permissions.SCHEDULE_AUTO,
Permissions.SCHEDULE_ADJUST,
],
teacher: [
Permissions.EXAM_CREATE,
@@ -55,6 +69,15 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.CLASS_ENROLL,
Permissions.CLASS_SCHEDULE,
Permissions.AI_CHAT,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_MANAGE,
Permissions.GRADE_RECORD_READ,
Permissions.COURSE_PLAN_READ,
Permissions.ATTENDANCE_MANAGE,
Permissions.ATTENDANCE_READ,
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
],
student: [
Permissions.EXAM_READ,
@@ -63,11 +86,23 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.TEXTBOOK_READ,
Permissions.CLASS_READ,
Permissions.AI_CHAT,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_READ,
Permissions.COURSE_PLAN_READ,
Permissions.ATTENDANCE_READ,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
],
parent: [
Permissions.EXAM_READ,
Permissions.TEXTBOOK_READ,
Permissions.CLASS_READ,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_READ,
Permissions.ATTENDANCE_READ,
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
],
grade_head: [
Permissions.EXAM_CREATE,
@@ -93,6 +128,13 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.CLASS_SCHEDULE,
Permissions.GRADE_MANAGE,
Permissions.AI_CHAT,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_READ,
Permissions.COURSE_PLAN_READ,
Permissions.ATTENDANCE_READ,
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
],
teaching_head: [
Permissions.EXAM_CREATE,
@@ -114,6 +156,13 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.CLASS_READ,
Permissions.GRADE_MANAGE,
Permissions.AI_CHAT,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_READ,
Permissions.COURSE_PLAN_READ,
Permissions.ATTENDANCE_READ,
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
],
}

View File

@@ -0,0 +1,119 @@
/**
* In-memory rate limiter (single-instance only).
*
* For multi-instance deployments, replace with @upstash/ratelimit +
* @upstash/redis. The API below mirrors the upstash `Ratelimiter.limit`
* shape so the swap is straightforward.
*
* Entries are pruned lazily on each call to keep the Map bounded.
*/
interface RateLimitEntry {
count: number
resetTime: number
}
const rateLimitMap = new Map<string, RateLimitEntry>()
/** Prune entries whose window has elapsed. Called on every limit check. */
function pruneExpired(now: number) {
if (rateLimitMap.size === 0) return
for (const [key, entry] of rateLimitMap.entries()) {
if (entry.resetTime <= now) {
rateLimitMap.delete(key)
}
}
}
export interface RateLimitResult {
success: boolean
remaining: number
resetTime: number
/** Milliseconds until the window resets (0 when already reset). */
retryAfterMs: number
}
export interface RateLimitParams {
/** Unique identifier for the bucket (e.g. `login:${ip}` or `ai:${userId}`). */
key: string
/** Maximum number of requests allowed within the window. */
limit: number
/** Window size in milliseconds. */
windowMs: number
}
/**
* Check whether a request should be allowed under the given rate limit.
* Increments the counter regardless of success (so repeated failures
* accumulate).
*/
export function rateLimit(params: RateLimitParams): RateLimitResult {
const now = Date.now()
pruneExpired(now)
const existing = rateLimitMap.get(params.key)
if (!existing || existing.resetTime <= now) {
// Start a fresh window
const resetTime = now + params.windowMs
rateLimitMap.set(params.key, { count: 1, resetTime })
return {
success: true,
remaining: params.limit - 1,
resetTime,
retryAfterMs: 0,
}
}
// Within an existing window
existing.count += 1
const remaining = Math.max(0, params.limit - existing.count)
const success = existing.count <= params.limit
const retryAfterMs = success ? 0 : existing.resetTime - now
return {
success,
remaining,
resetTime: existing.resetTime,
retryAfterMs,
}
}
/**
* Reset the counter for a key. Useful when a successful action should
* clear the failure count (e.g. successful login clears LOGIN limit).
*/
export function resetRateLimit(key: string): void {
rateLimitMap.delete(key)
}
/**
* Predefined rate limit rules for common scenarios.
* Times are in milliseconds.
*/
export const RATE_LIMIT_RULES = {
LOGIN: { limit: 5, windowMs: 15 * 60 * 1000 }, // 5 attempts per 15 minutes
API: { limit: 100, windowMs: 60 * 1000 }, // 100 requests per minute
UPLOAD: { limit: 10, windowMs: 60 * 1000 }, // 10 uploads per minute
AI_CHAT: { limit: 20, windowMs: 60 * 1000 }, // 20 chats per minute
PASSWORD_CHANGE: { limit: 5, windowMs: 60 * 1000 }, // 5 attempts per minute
} as const
/**
* Build a rate-limit key from a prefix and identifier.
*/
export function rateLimitKey(prefix: string, identifier: string): string {
return `${prefix}:${identifier}`
}
/**
* Convert a RateLimitResult into standard HTTP response headers.
*/
export function rateLimitHeaders(result: RateLimitResult): Record<string, string> {
return {
"X-RateLimit-Limit": String(result.remaining + (result.success ? 1 : 0)),
"X-RateLimit-Remaining": String(result.remaining),
"X-RateLimit-Reset": String(Math.ceil(result.resetTime / 1000)),
...(result.success ? {} : { "Retry-After": String(Math.ceil(result.retryAfterMs / 1000)) }),
}
}

View File

@@ -0,0 +1,74 @@
import "server-only"
import { mkdir, readFile, writeFile, unlink, access } from "fs/promises"
import path from "path"
/**
* Storage provider abstraction for file persistence.
* Allows swapping local disk storage for cloud providers (OSS, S3) without
* changing call sites.
*/
export interface StorageProvider {
/** Persist a file buffer at the given relative path; returns the public URL. */
save(file: Buffer, storagePath: string): Promise<string>
/** Read a file buffer by its relative storage path. */
read(storagePath: string): Promise<Buffer>
/** Remove a file by its relative storage path. */
delete(storagePath: string): Promise<void>
/** Check whether a file exists at the relative storage path. */
exists(storagePath: string): Promise<boolean>
/** Resolve the public URL for a relative storage path. */
getUrl(storagePath: string): string
}
/**
* Local disk storage provider.
* Files are persisted under `public/uploads/...` and served at `/uploads/...`.
*/
export class LocalStorageProvider implements StorageProvider {
private readonly publicDir = path.join(process.cwd(), "public")
async save(file: Buffer, storagePath: string): Promise<string> {
const absolutePath = path.join(this.publicDir, storagePath)
const dir = path.dirname(absolutePath)
await mkdir(dir, { recursive: true })
await writeFile(absolutePath, file)
return this.getUrl(storagePath)
}
async read(storagePath: string): Promise<Buffer> {
const absolutePath = path.join(this.publicDir, storagePath)
return readFile(absolutePath)
}
async delete(storagePath: string): Promise<void> {
const absolutePath = path.join(this.publicDir, storagePath)
try {
await unlink(absolutePath)
} catch {
// File may already be gone; ignore
}
}
async exists(storagePath: string): Promise<boolean> {
const absolutePath = path.join(this.publicDir, storagePath)
try {
await access(absolutePath)
return true
} catch {
return false
}
}
getUrl(storagePath: string): string {
// Storage paths are relative to public/; URLs start with "/"
if (storagePath.startsWith("/")) return storagePath
return `/${storagePath}`
}
}
/**
* Default storage provider (local disk).
* Swap this instance to migrate to OSS/S3 without touching call sites.
*/
export const storageProvider: StorageProvider = new LocalStorageProvider()

View File

@@ -47,6 +47,39 @@ export const Permissions = {
// Settings
SETTINGS_ADMIN: "settings:admin",
// Audit
AUDIT_LOG_READ: "audit_log:read",
// Announcement
ANNOUNCEMENT_MANAGE: "announcement:manage",
ANNOUNCEMENT_READ: "announcement:read",
// Grade Record (成绩录入与查询)
GRADE_RECORD_MANAGE: "grade_record:manage",
GRADE_RECORD_READ: "grade_record:read",
// File (文件上传与管理)
FILE_UPLOAD: "file:upload",
FILE_READ: "file:read",
FILE_DELETE: "file:delete",
// Course Plan (课程计划管理)
COURSE_PLAN_MANAGE: "course_plan:manage",
COURSE_PLAN_READ: "course_plan:read",
// Attendance (考勤管理)
ATTENDANCE_MANAGE: "attendance:manage",
ATTENDANCE_READ: "attendance:read",
// Message (站内消息)
MESSAGE_SEND: "message:send",
MESSAGE_READ: "message:read",
MESSAGE_DELETE: "message:delete",
// Scheduling (排课与调课)
SCHEDULE_AUTO: "schedule:auto",
SCHEDULE_ADJUST: "schedule:adjust",
} as const
export type Permission = (typeof Permissions)[keyof typeof Permissions]