Files
ClassHelper/packages/server/prisma/schema.prisma
T
LoveChengke 9d5ac7adf9 feat(shared,server,web,client,plugin)!: 角色与权限重构 + 学期周次 + 课表方向反转(1.2.0)
学生、任课关系、周次口径三处长期靠约定维持的事情,改为由数据模型保证。

学生不是账号
- 拆出独立 Student 表,没有 passwordHash / role / 任何登录字段
- 「学生不能登录」由表结构保证,不再靠「登录前判 role 再 403」的代码分支
- 三张个人数据表外键 userId → studentId;删掉 Enrollment 与 ClassTeacher
- 学生一律称「学号」、教师一律称「工号」,「学生账号」这个概念消失

任课关系唯一来源 = Course(班级 + 科目 + 教师)
- 班主任 = Class.teacherId,科任 = Course.teacherId,同一人两处指向同一账号、不重复建号
- 作业与成绩按科目再收一道:挂科目只由该科任课老师改,没挂科目归班主任 / 管理员
- 新增「任课教师分配」与「班级教师批量导入」(带「使用工号匹配」开关)

成绩与名单
- 成绩等级:百分制 / 等级制 A·B·C·D / 自定义,支持单个与批量改等级
- 按学号查成绩明细(教师端 + 班级端 + 设备令牌),受按班开关控制
- 调班 / 转出(单个与批量)+ 可查历史;学号不变、历史作业成绩留原归属
- 毕业归档页(按年度记录毕业班级 / 毕业生 / 转出学生 / 该届通知与作业)
- 学年升级(未毕业不归档、数据沿用);班级称呼改为「2026级1班」

ClassHelper 班级端在线状态
- Web 端显示 班级 / 设备名 / 设备号 / 在线离线 / 最后在线 / 最后心跳
- lastHeartbeatAt 驱动 60 秒在线窗口,与只表示「带状态上报」的 lastSeenAt 分开

学期周次
- 新增 TermWeek(classId 空串 = 全校默认),管理员可用日期范围选择器逐周配置
- 一键按开学日期生成整学期;联网拉取调休建议(只给建议、不改数据,无外网失败属正常)
- 改周次后随课表下发给教室的 ClassHelper 班级端

课表方向反转
- 主方向改为「服务端 → 教室」自动下发;教室不再自动回传
- 取教室课表改为手动动作(点击前弹警告),插件按待办标记在下一次心跳补报

改名
- 班级小助手 → ClassHelper:界面、窗口标题、托盘、灵动岛、EXE、安装包、
  安装目录、systemd 描述、插件清单与设置页、官网、文档站、README

BREAKING CHANGE: 产品名与 EXE 名变更(班级小助手.exe → ClassHelper.exe),
安装目录随之改变,不支持覆盖安装,需先卸载旧版。
服务端首次启动会自动应用 6 个数据库迁移(学生行按 id 原样搬迁,历史数据不丢),
升级前请先备份。
2026-10-06 19:41:18 +08:00

519 lines
22 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("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// 教师担任班主任的班级(每班 1 人)
ownedClasses Class[] @relation("ClassOwner")
/// 教师任教的课程 —— 「班级 + 科目 + 教师」任课关系
courses Course[]
createdHomeworks Homework[] @relation("HomeworkCreator")
createdNotices Notification[] @relation("NotificationCreator")
@@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)。
/// 管理员改它会重算「当前第几周」,并随镜像课表下发给教室的 ClassIsland。
termStartDate String?
/// 通知显示位置(由 ClassHelper 班级端在设置页里选):
/// both=班级端与 ClassIsland 都弹 / client=只弹班级端 / classisland=只弹 ClassIsland
notificationChannel String @default("both")
/// 是否允许 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[]
timeLayouts TimeLayout[]
integrationDevices IntegrationDevice[]
classIslandPushes ClassIslandPush[]
@@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 导入)
/// / classisland=插件上报同步得到。
/// 插件每次全量上报时只清理「classisland 来源且本次未再上报」的行(解决"老师在 ClassIsland
/// 里删掉一节、服务端却永远留着"的幽灵课),老师手排的课一律不删。
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")
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
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])
}
/// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班的 ClassHelper 班级端)
///
/// 说明:
/// 1) 令牌是「设备级」的,不是用户级:插件在本机保存明文,服务端只存 sha256 哈希 + 前缀提示;
/// 2) 同一班级下按 deviceKey(机器码)唯一:插件重装后带同一 deviceKey 会自动重绑到原记录,
/// 避免设备列表里堆满重复项;
/// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课";
/// 4) `lastHeartbeatAt` 每次上报都刷新(在线判定用它),`lastSeenAt` 只在**带运行状态**的
/// 上报时刷新 —— 这样"能连上但一直没上报状态"的设备不会被误判成完全正常。
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 的课表回传到本服务
///
/// **默认关闭**(2026-10-06 起):课表以服务端为准,由服务端**自动下发**到教室;
/// 「从教室机器取一次课表」改成 Web 端的手动动作(点击前会弹警告),
/// 因为自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
syncScheduleToServer Boolean @default(false)
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)。**默认开启** —— 这是现在的主方向。
mirrorScheduleToClassIsland Boolean @default(true)
/// 待办的「立即上报一次课表」请求:Web 端点「从教室机器获取课表」时置位,
/// 插件在下一次心跳看到它就把当前课表推上来并清除标记。
requestScheduleReport Boolean @default(false)
classIslandVersion String?
pluginVersion String?
/// 最后一次**带运行状态**的上报时间
lastSeenAt DateTime?
/// 最后一次心跳时间(每次上报都刷新);「在线」看它
lastHeartbeatAt 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])
}