// 班级小助手 - 数据模型 // // 设计说明: // 1) 本 schema 使用 sqlite provider(本地 MVP / 离线开发)。 // 字段全部使用 String / Int / Float / Boolean / DateTime 等跨库通用类型, // 不使用 Prisma enum 与 @db.* 原生类型,因此可平移到 MySQL。 // MySQL 版本见同目录 schema.mysql.prisma,切换步骤见 docs/mysql.md。 // 2) 角色与优先级用 String 存储('ADMIN' | 'TEACHER' | 'STUDENT'、 // 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'),在 API 层用 zod 校验, // 这样 SQLite / MySQL 行为完全一致。 // 3) 所有主键使用 cuid 字符串,便于多端离线生成与合并。 generator client { provider = "prisma-client" output = "../src/generated/prisma" } datasource db { provider = "sqlite" } /// 用户:教师 / 管理员 / 学生统一存储 model User { id String @id @default(cuid()) username String @unique passwordHash String name String role String @default("STUDENT") classId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // 学生的主班级(一个学生属于一个主班级) class Class? @relation("ClassStudents", fields: [classId], references: [id], onDelete: SetNull) // 教师创建(负责)的班级 ownedClasses Class[] @relation("ClassOwner") // 教师被分配协作的班级 classAssignments ClassTeacher[] courses Course[] homeworkStatuses HomeworkStatus[] notificationReads NotificationRead[] grades Grade[] enrollments Enrollment[] createdHomeworks Homework[] @relation("HomeworkCreator") createdNotices Notification[] @relation("NotificationCreator") @@index([classId]) @@index([role]) } /// 班级 model Class { id String @id @default(cuid()) name String grade String teacherId String /// 班级码:学生端「班级账号」登录用(唯一,字母数字,4~16 位,存大写) code String @unique /// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级账号登录 passwordHash String? /// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它 termWeeks Int @default(20) /// 通知显示位置(由教室的班级客户端在设置页里选): /// both=客户端与 ClassIsland 都弹 / client=只弹客户端 / classisland=只弹 ClassIsland notificationChannel String @default("both") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt teacher User @relation("ClassOwner", fields: [teacherId], references: [id], onDelete: Cascade) students User[] @relation("ClassStudents") teachers ClassTeacher[] courses Course[] schedules Schedule[] homeworks Homework[] notifications Notification[] grades Grade[] enrollments Enrollment[] timeLayouts TimeLayout[] integrationDevices IntegrationDevice[] classIslandPushes ClassIslandPush[] @@index([teacherId]) } /// 教师-班级 分配关系(一个教师可管理多个班级) model ClassTeacher { id String @id @default(cuid()) classId String teacherId String createdAt DateTime @default(now()) class Class @relation(fields: [classId], references: [id], onDelete: Cascade) teacher User @relation(fields: [teacherId], references: [id], onDelete: Cascade) @@unique([classId, teacherId]) @@index([teacherId]) } /// 学生-班级 选课/归属记录(与 User.classId 保持同步,支持未来多班) model Enrollment { id String @id @default(cuid()) userId String classId String createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) class Class @relation(fields: [classId], references: [id], onDelete: Cascade) @@unique([userId, classId]) @@index([classId]) } /// 课程 model Course { id String @id @default(cuid()) name String teacherId String classId String createdAt DateTime @default(now()) teacher User @relation(fields: [teacherId], references: [id], onDelete: Cascade) class Class @relation(fields: [classId], references: [id], onDelete: Cascade) schedules Schedule[] homeworks Homework[] grades Grade[] @@unique([classId, name]) @@index([classId]) } /// 课表条目 model Schedule { id String @id @default(cuid()) classId String courseId String /// 1=周一 ... 7=周日 dayOfWeek Int /// HH:mm startTime String /// HH:mm endTime String location String? /// 学期起始周次(含) weekStart Int @default(1) /// 学期结束周次(含) weekEnd Int @default(20) /// 单双周:ALL=每周(默认)/ ODD=单周 / EVEN=双周(ClassIsland WeekCountDiv + WeekCountDivTotal 的映射) weekParity String @default("ALL") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt class Class @relation(fields: [classId], references: [id], onDelete: Cascade) course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) @@index([classId, dayOfWeek]) @@index([courseId]) } /// 作业 model Homework { id String @id @default(cuid()) classId String courseId String? title String content String /// 作业所属日期(YYYY-MM-DD,本地日期,不带时区):客户端/Web 的按天查看与日期高亮都用它 assignDate String @default("") attachmentUrl String? /// 遗留字段:作业"截止时间"功能已下线(UI/接口均不再读写)。 /// 保留列以免对已安装的库做破坏性迁移;新库该列恒为 null。 dueAt DateTime? createdBy String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt class Class @relation(fields: [classId], references: [id], onDelete: Cascade) course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull) creator User @relation("HomeworkCreator", fields: [createdBy], references: [id], onDelete: Cascade) statuses HomeworkStatus[] @@index([classId, createdAt]) @@index([classId, assignDate]) @@index([courseId]) @@index([dueAt]) } /// 学生作业完成状态 model HomeworkStatus { id String @id @default(cuid()) homeworkId String userId String completed Boolean @default(false) updatedAt DateTime @updatedAt homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([homeworkId, userId]) @@index([userId]) } /// 通知 model Notification { id String @id @default(cuid()) classId String title String content String priority String @default("NORMAL") createdBy String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt class Class @relation(fields: [classId], references: [id], onDelete: Cascade) creator User @relation("NotificationCreator", fields: [createdBy], references: [id], onDelete: Cascade) reads NotificationRead[] @@index([classId, createdAt]) } /// 通知已读记录 model NotificationRead { id String @id @default(cuid()) notificationId String userId String readAt DateTime @default(now()) notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([notificationId, userId]) @@index([userId]) } /// 课表时间配置(ClassIsland 同款 JSON 导入后的节次时间表) model TimeLayout { id String @id @default(cuid()) classId String name String /// 来源:classisland | manual source String @default("classisland") /// 节次数组(JSON 字符串):[{ index, name, startTime, endTime, type, skipped }] items String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt class Class @relation(fields: [classId], references: [id], onDelete: Cascade) @@index([classId]) } /// 成绩 model Grade { id String @id @default(cuid()) classId String courseId String? userId String examName String score Float totalScore Float @default(100) publishedAt DateTime @default(now()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt class Class @relation(fields: [classId], references: [id], onDelete: Cascade) course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull) student User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, publishedAt]) @@index([classId, examName]) @@index([courseId]) } /// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班级的接入端) /// /// 说明: /// 1) 令牌是「设备级」的,不是用户级:插件在本机保存明文,服务端只存 sha256 哈希 + 前缀提示; /// 2) 同一班级下按 deviceKey(机器码)唯一:插件重装后带同一 deviceKey 会自动重绑到原记录, /// 避免设备列表里堆满重复项; /// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课"。 model IntegrationDevice { id String @id @default(cuid()) classId String /// 设备显示名(默认取机器名) name String /// 设备唯一标识(插件上报的机器码) deviceKey String /// 联动方式:plugin=实时插件联动 / import=仅一次性导入 mode String @default("plugin") /// 是否接入(关闭后插件上报与下发一律 403) enabled Boolean @default(true) /// 设备令牌的 sha256(明文只在创建/重置时返回一次) tokenHash String /// 令牌前缀(chci_xxxxxxxx),设备列表里用于人眼识别与排查 tokenHint String /// 是否允许插件把 ClassIsland 的课表回传到本服务 syncScheduleToServer Boolean @default(true) /// 是否允许把本班课表镜像回 ClassIsland(由插件执行) mirrorScheduleToClassIsland Boolean @default(false) classIslandVersion String? pluginVersion String? lastSeenAt DateTime? /// 以下为最后一次上报的快照 classPlanLoaded Boolean @default(false) currentSubject String? currentTimeState String? currentPeriodStart String? currentPeriodEnd String? nextSubject String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt class Class @relation(fields: [classId], references: [id], onDelete: Cascade) pushes ClassIslandPush[] @@unique([classId, deviceKey]) @@index([classId]) @@index([tokenHash]) } /// 下发给 ClassIsland 的提醒:教师发通知 → 学生机器全屏弹出。 /// 落库是为了「设备离线时通知不丢」——插件重连后拉取未确认的一条立即弹出。 model ClassIslandPush { id String @id @default(cuid()) classId String deviceId String? title String content String /// 显示时长(秒):映射到 ClassIsland 的 NotificationContent.Duration durationSeconds Int @default(8) /// 语音朗读内容;null = 不朗读 speechContent String? urgent Boolean @default(false) /// 提醒类型:notification=通知类 / call=叫人(叫人算「主动通知」,上课时段也立刻弹) kind String @default("notification") /// 发起人的用户 id(教师/管理员) createdBy String? /// 同时落库到通知中心的通知 id(saveToNotifications 时写入) notificationId String? /// 插件确认弹出的时间(确认后不再重复下发) ackedAt DateTime? createdAt DateTime @default(now()) /// 过期时间:超过后不再补发(避免学生几天后突然弹出旧提醒) expiresAt DateTime class Class @relation(fields: [classId], references: [id], onDelete: Cascade) device IntegrationDevice? @relation(fields: [deviceId], references: [id], onDelete: SetNull) @@index([classId, createdAt]) @@index([deviceId, ackedAt]) }