Files
ClassHelper/packages/server/prisma/schema.prisma
T

618 lines
25 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ClassHelper - 数据模型
//
// 设计说明:
// 1) 本 schema 使用 sqlite provider(本地 MVP / 离线开发)。
// 字段全部使用 String / Int / Float / Boolean / DateTime 等跨库通用类型,
// 不使用 Prisma enum 与 @db.* 原生类型,因此可平移到 MySQL。
// MySQL 复用这一份 schema(就地改写下面的 provider,见 `pnpm db:switch:mysql`),
// 没有第二份 schema.mysql.prisma;切换步骤见 docs/mysql.md。
// 2) 角色与优先级用 String 存储('ADMIN' | 'TEACHER' | 'CLASS_DEVICE'、
// 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'),在 API 层用 zod 校验,
// 这样 SQLite / MySQL 行为完全一致。
// 3) 所有主键使用 cuid 字符串,便于多端离线生成与合并。
//
// **学生不是账号**(2026-10-06 重构的核心不变量):
// `Student` 是独立的一张表,只有学号 / 姓名 / 班级 / 性别 / 家长手机号,
// 没有 passwordHash、没有 role、没有登录字段 —— 「学生不能登录」由表结构保证,
// 不再靠"登录前先判 role === 'STUDENT' 并 403"这类代码分支维持。
// `User` 因此收缩为**纯账号表**(管理员 / 教师 / ClassHelper 班级端)。
//
// **任课关系唯一来源是 `Course`**:`Course(classId, name, teacherId)` 就是
// 「班级 + 科目 + 教师」这条关系本身。班主任另由 `Class.teacherId` 表示(每班 1 人)。
// 历史上那张没有科目的 `ClassTeacher` 协作表已删除 —— 两套关系并存必然漂移。
// 同一教师可以在同一个班同时是班主任和某科科任老师:两处都指向同一个 `User`,
// 不重复建号,权限在 `@classhelper/shared` 的 `resolveClassRole()` 里合并。
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "sqlite"
}
/// 账号:管理员 / 教师 / ClassHelper 班级端。**学生不在这里**(见 Student)。
model User {
id String @id @default(cuid())
/// 登录名:教师即**工号**,班级端即班级码
username String @unique
passwordHash String
name String
/// ADMIN | TEACHER | CLASS_DEVICE
role String @default("TEACHER")
/// 手机号(教师联系方式,可空)
phone String @default("")
/// 关怀模式(大字版界面):本人开关,Web 端与客户端各记一份
careMode Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// 教师担任班主任的班级(每班 1 人)
ownedClasses Class[] @relation("ClassOwner")
/// 教师任教的课程 —— 「班级 + 科目 + 教师」任课关系
courses Course[]
createdHomeworks Homework[] @relation("HomeworkCreator")
createdNotices Notification[] @relation("NotificationCreator")
/// 分配的权限组;**没有任何分组时**回落角色默认模板(见 shared 的 ROLE_DEFAULT_PERMISSIONS)
permissionGroups UserPermissionGroup[]
@@index([role])
}
/// 学生:**只是班级名单里的一条记录**,没有账号、没有密码、不能登录。
///
/// `studentNo`(学号)是学生在系统里的唯一标识与查询键:
/// 名单、成绩、作业提交、导入模板、导出、查询显示一律用它。
/// 历史作业与成绩挂在 `Homework.classId` / `Grade.classId` 上,
/// 因此**调班只改本表的 classId,历史数据保留原归属**。
model Student {
id String @id @default(cuid())
/// 学号:学生唯一标识(全校唯一)
studentNo String @unique
name String
/// 当前班级;null = 未分班(转出/毕业后班级归属仍保留,靠 status 区分)
classId String?
/// 性别:'' | 'MALE' | 'FEMALE'(字符串存,跨库通用)
gender String @default("")
/// 家长手机号(可空)
guardianPhone String @default("")
/// active = 在读 / graduated = 已毕业 / transferred = 已转出 / inactive = 停用
status String @default("active")
/// 毕业归档所属届别(`ArchivedYear.id`);null = 未归档
archivedYearId String?
archivedAt DateTime?
/// 转出时间(status='transferred' 时写入)
transferredAt DateTime?
/// 转出去向 / 备注
transferNote String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class? @relation(fields: [classId], references: [id], onDelete: SetNull)
archivedYear ArchivedYear? @relation(fields: [archivedYearId], references: [id], onDelete: SetNull)
homeworkStatuses HomeworkStatus[]
notificationReads NotificationRead[]
grades Grade[]
transfers StudentClassTransfer[]
@@index([classId])
@@index([name])
@@index([status])
@@index([archivedYearId])
}
/// 调班 / 转出历史:原班级 / 新班级 / 操作人 / 时间。
///
/// 班级 id 与班级名**都存快照且不建外键** —— 班级被删除后这条历史仍要能读出来,
/// 而外键的 Cascade 会把历史一起带走。
model StudentClassTransfer {
id String @id @default(cuid())
studentId String
/// 学号快照(学生被删除后历史仍可读)
studentNo String
studentName String
fromClassId String?
fromClassName String @default("")
toClassId String?
toClassName String @default("")
/// 操作人(User.id);账号被删后留空,姓名快照保留
operatorId String?
operatorName String @default("")
/// single = 单个调班 / batch = 批量调班 / transfer-out = 转出(离开本校)
mode String @default("single")
note String @default("")
createdAt DateTime @default(now())
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@index([studentId, createdAt])
@@index([createdAt])
}
/// 届别档案:一次「毕业归档」的留痕(管理员在「归档管理」页执行)。
///
/// 归档是**打标记 + 只读**,不删任何数据:
/// - `Class.archivedYearId` / `Student.archivedYearId` 指向本届档案,
/// 常规的班级/学生列表按它过滤掉归档项;
/// - 毕业班级的作业与通知原样留在库里,通过归档详情只读查看
/// (需求「记录每个年度毕业班级的消息和作业」就是这么满足的)。
///
/// 「未毕业而升级的班级不归档」:升级只改 `Class.grade`(高一→高二→高三),
/// 班级记录、学生名单、作业成绩全部沿用,不碰 `archivedYearId`。
model ArchivedYear {
id String @id @default(cuid())
/// 届别 = 入学年份(2026 表示 2026 级,班级称呼因此是「2026级1班」)
enrollmentYear Int @unique
/// 毕业年份(= 入学年份 + 学制年数)
graduationYear Int
/// 届别名,如「2026 级」
name String
note String @default("")
operatorId String?
operatorName String @default("")
/// 归档时的统计快照(给人看的一行汇总;明细实时查班级与学生)
classCount Int @default(0)
studentCount Int @default(0)
transferredCount Int @default(0)
homeworkCount Int @default(0)
notificationCount Int @default(0)
archivedAt DateTime @default(now())
classes Class[]
students Student[]
}
/// 学期周次区间:管理员可以为**每一个教学周**指定实际的起止日期。
///
/// 为什么不能只靠"学期开始日期 + 每周七天"线性推算:法定节假日调休、周末补课、
/// 错峰开学都会让某一周变长或变短(甚至整周不上课),线性推算出来的"第几周"
/// 会与教室里的实际情况整体错位一周以上 —— 而课表、作业、成绩都按周次组织。
///
/// 因此支持逐周设置:
/// `classId = ''` 表示**全校默认**(没有为某个班单独配置时用它);
/// 有班级维度的行则覆盖默认。
/// 没配置的周次仍按学期开始日期线性兜底(见 shared 的 `resolveCurrentWeek`)。
model TermWeek {
id String @id @default(cuid())
/// 归属班级;**空串 = 全校默认**(用空串而不是 null,唯一约束才在两个库上都生效)
classId String @default("")
/// 教学周次(1 起)
weekNumber Int
/// 该周的起始日期(YYYY-MM-DD,本地日期)
startDate String
/// 该周的结束日期(YYYY-MM-DD)
endDate String
note String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([classId, weekNumber])
@@index([classId])
}
/// 班级
///
/// **班级称呼是「XXXX级X班」**(入学年份 + 班号),不是「高一(1)班」:
/// 后者每年都要改,前者一路跟着这届学生到毕业。`name` 由服务端按
/// `enrollmentYear` + `classIndex` 生成(见 shared 的 `formatClassName()`),
/// 保留成列是为了兼容既有引用,不在别处手写。
///
/// `grade`(高一/高二/高三)表示**当前年级**,学年升级时批量改它即可 ——
/// 升级只动这一个字段,班级记录、学生名单、作业与成绩全部沿用,**不归档**。
model Class {
id String @id @default(cuid())
/// 班级称呼:由 enrollmentYear + classIndex 生成,如「2026级1班」
name String
/// 当前年级(高一 / 高二 / 高三),学年升级时更新
grade String
/// 入学年份(届别):2026 → 称呼「2026级…」、毕业年份 2029
enrollmentYear Int?
/// 班号(同一届内的序号)
classIndex Int?
/// 班主任(每班 1 人);科任老师按科目配在 Course 上
teacherId String
/// 班级码:ClassHelper 班级端登录用(唯一,字母数字,4~16 位,存大写)
code String @unique
/// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级端登录
passwordHash String?
/// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它
termWeeks Int @default(20)
/// 本班学期开始日期(YYYY-MM-DD,本地日期);留空则用全局配置(.env 的 TERM_START_DATE)。
/// 管理员改它会重算「当前第几周」,并广播课表更新。
termStartDate String?
/// 是否允许 ClassHelper 班级端按学号查询本班学生的成绩明细(默认开启)。
/// 关闭后班级端查成绩一律 403;教师端不受影响。
studentGradeQueryEnabled Boolean @default(true)
/// 毕业归档所属届别;null = 在读(常规班级列表只显示未归档的)
archivedYearId String?
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
teacher User @relation("ClassOwner", fields: [teacherId], references: [id], onDelete: Cascade)
archivedYear ArchivedYear? @relation(fields: [archivedYearId], references: [id], onDelete: SetNull)
students Student[]
courses Course[]
schedules Schedule[]
homeworks Homework[]
notifications Notification[]
grades Grade[]
exams Exam[]
timeLayouts TimeLayout[]
clientDevices ClientDevice[]
@@index([teacherId])
@@index([enrollmentYear])
@@index([archivedYearId])
}
/// 课程:同时也是「班级 + 科目 + 教师」这条**任课关系**的载体。
///
/// `teacherId` 就是该班该科的任课老师(管理员在「班级管理 → 任课老师」里指定)。
/// 作业与成绩都挂在 Course 上,因此"这位老师能不能动这一科的作业/成绩"
/// 就是查 `Course.teacherId`—— 不需要第二张任课表。
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])
@@index([teacherId])
}
/// 课表条目
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")
/// 该行的来源:manual=手工建立(Web 端录入 / ClassIsland JSON 导入)
source String @default("manual")
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
studentId String
completed Boolean @default(false)
updatedAt DateTime @updatedAt
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@unique([homeworkId, studentId])
@@index([studentId])
}
/// 通知
model Notification {
id String @id @default(cuid())
classId String
title String
content String
priority String @default("NORMAL")
/// 发布所属日期(**本地日期 YYYY-MM-DD,不带时区**):按天归档的唯一依据。
/// 与 Homework.assignDate 同口径(shared 的 dayKeyLocal),**不要**用 createdAt 切 UTC。
assignDate String @default("")
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])
@@index([classId, assignDate])
}
/// 通知已读记录
model NotificationRead {
id String @id @default(cuid())
notificationId String
studentId String
readAt DateTime @default(now())
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@unique([notificationId, studentId])
@@index([studentId])
}
/// 课表时间配置(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])
}
/// 成绩
///
/// `classId` 是**发布时的归属班级**,调班不改变它 —— 历史成绩留在原班级,
/// 后续新成绩按学生当前班级生成(需求「历史作业和成绩保留原归属」)。
model Grade {
id String @id @default(cuid())
classId String
courseId String?
/// 学生(名单记录)id
studentId String
examName String
score Float
totalScore Float @default(100)
/// 等级口径:percent 百分制 / letter 等级制 A-D / custom 自定义等级
levelType String @default("percent")
/// 等级文本。percent 下由服务端按得分率生成初值,教师可手改覆盖
level String @default("")
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 Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@index([studentId, publishedAt])
@@index([classId, examName])
@@index([courseId])
}
model PermissionGroup {
/// 角色默认组;自定义组为空,改名不影响对应角色。
defaultRole String? @unique
id String @id @default(cuid())
name String @unique
description String @default("")
/// 内置组:只在种子/引导时创建,管理员不可删除(可改成员与键)
builtin Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items PermissionGroupItem[]
users UserPermissionGroup[]
}
/// 权限组里的一个权限键(一行一个键,便于查询与去重)
model PermissionGroupItem {
id String @id @default(cuid())
groupId String
/// 权限键,取值必须是 shared 的 PERMISSION_KEYS 之一(服务端 zod 校验)
key String
createdAt DateTime @default(now())
group PermissionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@unique([groupId, key])
@@index([groupId])
}
/// 账号 ←→ 权限组 的分配关系(一个账号可属多个组)
model UserPermissionGroup {
id String @id @default(cuid())
userId String
groupId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
group PermissionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@unique([userId, groupId])
@@index([userId])
@@index([groupId])
}
/// 全局键值设置(大模型配置、开发者模式等)。
///
/// 值一律存字符串(复杂结构存 JSON 文本),键名常量在 `src/lib/settings.ts`;
/// 密钥类值(如大模型 API Key)加密后存这里,接口**只进不出**。
model AppSetting {
key String @id
value String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
/// ClassHelper 客户端设备(装在学生机/教室机上的桌面客户端)。
///
/// 身份由客户端主进程生成并持久化的 `deviceKey`(UUID)标识,心跳每 30 秒一次:
/// 服务端由此知道**每台机器在哪个班、版本多少、是否在线**,并把更新与远控
/// 指令按「查库 + 心跳拉取」的范式下发,不新增长连接握手。
model ClientDevice {
id String @id @default(cuid())
/// 该设备当前登录的班级(未登录时为空)
classId String?
/// 设备唯一标识(客户端主进程生成,UUID)
deviceKey String @unique
/// 设备名(默认取 os.hostname(),管理员可改名)
name String @default("")
/// 客户端版本号(来自 app.getVersion())
clientVersion String @default("")
/// 系统与架构,如 win32-x64
platform String @default("")
/// 是否接受服务端统一下发的自动更新
autoUpdate Boolean @default(true)
/// 服务端指定的目标版本('' = 不指定,按最新版走)
updateTargetVersion String @default("")
updateMode String @default("idle")
updateNotBefore DateTime?
/// 已下载待安装的版本('' = 无)
pendingVersion String @default("")
/// 最近一次心跳时间(**在线判定看它**)
lastHeartbeatAt DateTime?
/// 最近一次心跳来源 IP(排查用)
lastIp String @default("")
/// 客户端上报的当前状态说明(如"上课中/已隐藏",仅展示用)
stateNote String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class? @relation(fields: [classId], references: [id], onDelete: SetNull)
commands ClientCommand[]
@@index([classId])
@@index([lastHeartbeatAt])
}
/// 下发给客户端设备的指令(远控:关机 / 重启 / 注销 / 锁屏)。
///
/// 客户端心跳时拉取未确认且未过期的指令,**执行前必须在界面上二次确认**,
/// 执行后回执(`ackedAt` + `result`)。
model ClientCommand {
id String @id @default(cuid())
deviceId String
/// shutdown | restart | logoff | lock
kind String
/// 附加参数(JSON 文本,预留:延时秒数、提示语)
payload String @default("")
createdBy String?
createdAt DateTime @default(now())
/// 过期时间:超过后不再下发(避免机器离线后几天突然关机)
expiresAt DateTime?
/// 客户端确认执行的时间
ackedAt DateTime?
/// 执行结果说明(成功 / 失败原因)
result String @default("")
/// 管理员撤销的时间(非空即不再下发)
cancelledAt DateTime?
device ClientDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
@@index([deviceId, ackedAt])
@@index([createdAt])
}
/// 服务端缓存的客户端安装包:教室机器**没有外网**时也能统一更新。
///
/// 管理员上传后落在 `data/client-releases/`,客户端从
/// `GET /api/update/client/download/:version` 下载(继承守卫与限流)。
model ClientRelease {
id String @id @default(cuid())
version String @unique
fileName String
/// 安装包 sha256(客户端下载后必须校验)
sha256 String @default("")
/// 字节数(管理员上传时记录,供下载进度与展示用)
size Int @default(0)
/// 发布说明(展示给管理员)
notes String @default("")
createdBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
/// 大模型指令审计:谁在什么时候用"一句话"做了什么、结果如何。
///
/// 只记**意图与结果**,不记密钥;`rawReply` 供排查模型输出格式问题时查看。
model AiAudit {
id String @id @default(cuid())
userId String
/// 用户原话
prompt String
/// 模型解析出的意图(如 homework.publish / notification.publish / unknown)
intent String @default("")
/// pending | confirmed | rejected | failed
status String @default("pending")
/// 结果说明(失败原因、生成的标题等)
message String @default("")
/// 模型原始回复(截断保存,排查用)
rawReply String @default("")
createdAt DateTime @default(now())
@@index([userId, createdAt])
}
/// 一次考试(月考 / 期中考…)在本班的设置。
///
/// 需求:「单次考试在导入成绩时可选是否将所有科目相加以总分排名,客户端也能看到总分排名」。
/// `Grade.examName` 是自由文本,服务端光看它不知道这次考试算不算总分,而客户端要显示总分榜
/// 就必须知道 —— 于是按「班级 + 考试名」记一条。
///
/// 与 `Grade` 是**弱关联**(两边只按名字匹配,不建外键):成绩删光也不会连带删掉设置,
/// 补录同一场考试时设置还在;反过来留一条没有成绩的设置是无害的。
model Exam {
id String @id @default(cuid())
classId String
/// 考试名,与 `Grade.examName` 同口径(如「第一次月考」)
name String
/// 是否把所有科目(本次考试已录入的)相加做总分排名
countInTotal Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
@@unique([classId, name])
@@index([classId])
}