Files
ClassHelper/packages/server/prisma/schema.prisma
T
LoveChengke 817af02357 feat(permissions,clients,ai,system,server,web,client,shared)!: 权限组 + 客户端设备远控与自动更新 + 大模型助手 + 系统诊断(1.3.0)
功能级准入改为「权限键」,`requireRole` 退成默认模板。

权限组
- 权限目录(39 键:键 / 中文名 / 分类 / 默认角色)收口到 shared,路由挂 requirePermission / requireAnyPermission
- 有效权限每请求实时算,改完分组立刻生效:ADMIN 全集且 permission:manage、system:manage 不可剥夺;
  CLASS_DEVICE 空集(不受分组影响);有分组 = 并集(因此也能用来**收回**权限);无分组 = 角色默认模板
- 新增「权限组」页:组增删改、成员分配、生效权限预览
- 班级维度的科目规则(assertCanManageSubjectContent)故意不可被权限组覆盖

客户端设备(ClassHelper 桌面端)
- 设备身份是 deviceKey(首次启动生成的 UUID,saveConfig 拒绝改写、退出登录也保留)
- 心跳走「DB 记状态 + 客户端定时拉」,不引入新的长连接:30 秒一次,在线窗口 90 秒
- 远控命令 shutdown / restart / logoff / lock:服务端有效期 10 分钟,客户端弹默认选中「取消」的对话框、
  60 秒未确认按取消处理,执行后必须回执(重复回执幂等),同一台机器同一条命令只弹一次
- 新增「客户端设备」页:版本一览、一键推送更新、下发远控

客户端安装包由服务端下载并缓存(教室机常常没有外网,浏览器也受 CSP 限制)
- POST /api/update/client/fetch 由服务端下载到 data/client-releases/<版本>/ 并登记 sha256 + size
- 自动安装三条护栏:没有 sha256 就拒收、必须等到非上课且主窗口隐藏满 5 分钟、冒烟模式永不安装

大模型助手:只解析、不落库
- POST /api/ai/interpret 返回**待确认**的结构化动作(建作业 / 发通知 / 叫人),
  真正落库仍调既有端点 —— 权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用
- 模型给的 params 用与手动入口**同一份** zod 再校验(模型输出不可信)
- API Key 走 AES-256-GCM(密钥由 JWT_SECRET 经 scrypt 派生)加密存 AppSetting,只进不出
- provider=mock 是离线确定性解析,自动化验收一律用它;每次解析写一条 AiAudit(不含密钥)

系统与运维
- 开发者模式(服务端内存缓存开关,热路径不查库;开启后 500 才附 error.stack)、运行诊断(绝不回传密钥)
- 开机自启动运行期可改:Windows 改 HKCU\...\Run、Linux systemctl enable/disable,
  **非安装形态一律返回 supported:false**,不往开发机的注册表里写自启项
- 服务端侧栏折叠只用 CSS 收拢(不重建 DOM),关怀模式只放大字号与控件、不动任何几何令牌

客户端
- 崩溃自愈:渲染进程挂掉 / 超 10 秒无响应先静默重建主窗口(10 分钟最多 3 次),
  未捕获异常只在「没在上课、没人看着」时才重启进程;冒烟模式绝不重启(会杀掉验证实例)
- 上线补弹离线期间收到的消息只做一次:起点只在「可达 → 不可达」的边界写,恢复时取最近 5 条按时间升序推

发布内容批量
- POST /notifications/bulk、POST /homeworks/bulk:**单班失败不影响整批**,逐班返回结果
- 作业的科目名跨班**按名匹配**已有课程,找不到就整班记失败,绝不自动建课
  (自动建课会把创建者写成任课老师,等于越权改任教关系)
- 通知与作业同口径按「所属日期」归档,新增 /notifications/days 与 /notifications/creators

BREAKING CHANGE: 能不能做某件事不再看角色名,而是看权限键(GET /api/permissions/me)。
未分组的账号按角色默认模板生效,行为与 1.2.0 一致;分组之后生效集合只由组的并集决定。
服务端首次启动会自动应用 1 个数据库迁移(权限组 3 表 / AppSetting / 客户端设备·命令·安装包 /
AiAudit / Notification.assignDate / User.careMode,历史通知按本地日期回填),升级前请先备份。
2026-10-11 11:49:32 +08:00

699 lines
29 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)。
/// 管理员改它会重算「当前第几周」,并随镜像课表下发给教室的 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[]
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 导入)
/// / 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")
/// 发布所属日期(**本地日期 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])
}
/// 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])
}
/* -------------------------------------------------------------------------- */
/* 权限组 / 全局设置 / 客户端设备 / 远控命令 / 客户端安装包 / 大模型审计 */
/* -------------------------------------------------------------------------- */
/// 权限组:管理员自定义的一组**权限键**(`shared` 的 `PERMISSION_KEYS`),可分配给多个账号。
///
/// 语义(与 `packages/shared/src/permissions.ts` 的目录一一对应):
/// - 账号**没有**任何 `UserPermissionGroup` 时,生效集合 = 角色默认模板(`ROLE_DEFAULT_PERMISSIONS`);
/// - 一旦分了组,生效集合 = 这些组的键并集(可以比默认更少 ⇒ 也能用来**收回**权限);
/// - `ADMIN` 永远补上 `permission:manage` / `system:manage`,避免把自己锁死。
model PermissionGroup {
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 秒一次:
/// 服务端由此知道**每台机器在哪个班、版本多少、是否在线**,并把更新与远控
/// 指令按「查库 + 心跳拉取」的范式下发(不新增长连接握手,照抄
/// `IntegrationDevice.requestScheduleReport` 的做法)。
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("")
/// 已下载待安装的版本('' = 无)
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])
}