Files
ClassHelper/packages/server/prisma/schema.prisma
T
LoveChengke 7ea0e0f7c9 feat(schedules,classisland,server,plugin,shared): 课表行加来源字段,插件上报可真正同步删除
课表行新增 Schedule.source(manual / classisland):插件每次全量上报时,服务端
只清理「source=classisland 且本次未再上报」的行。原先 merge 只做 upsert,老师在
ClassIsland 里删掉或挪走一节,服务端会永远留着那行幽灵课;现在能真正同步下来,
而老师在小助手这边手排的课(manual)一律不删。

配套改动:
- 插件上报时带回 machineKey,服务端回填到设备记录(避开 [classId, deviceKey] 唯一约束);
- 新增迁移 20260930120000_add_schedule_source,存量数据保守地按 manual 处理;
- verify:classisland 补「幽灵行清理」「手排课不被误删」用例。

另:.gitignore 补上 .zcode/ 与 .zcodeignore(AI 会话产物,不属于产品代码)。
2026-10-02 00:24:58 +08:00

357 lines
13 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.
// 班级小助手 - 数据模型
//
// 设计说明:
// 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")
/// 该行的来源: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
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])
}