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

@@ -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),
}));