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,历史通知按本地日期回填),升级前请先备份。
This commit is contained in:
LoveChengke committed 2026-10-11 11:49:32 +08:00
1 parent e51879667a
commit 817af02357
131 files changed
+12995 -538

No files matched your search

@@ -0,0 +1,173 @@
-- 权限组 / 全局设置 / 客户端设备与远控 / 客户端安装包缓存 / 大模型审计
--
-- 一、权限组(需求:「允许 admin 设置各用户权限,或创建权限组」)
-- `PermissionGroup` 权限组本体(名字 + 说明 + 是否内置)
-- `PermissionGroupItem` 组里的一条权限键(键目录在 shared 的 PERMISSION_KEYS)
-- `UserPermissionGroup` 账号与组的分配关系(一个账号可属多个组)
-- 语义:账号没有任何分组时回落角色默认模板(ROLE_DEFAULT_PERMISSIONS,逐条镜像改造前的
-- 路由 requireRole);一旦分组,生效集合只由组的并集决定(因此也能用来收回权限)。
--
-- 二、全局设置 `AppSetting`(大模型开关与配置、开发者模式等)——值一律字符串,密钥加密后存。
--
-- 三、客户端设备与统一更新(需求:「服务端显示各班 ClassHelper 版本,可一键推送更新」)
-- `ClientDevice` 每台客户端一条:班级 / 版本 / 平台 / 是否自动更新 / 目标版本 / 心跳时间
-- `ClientCommand` 远控指令(关机 / 重启 / 注销 / 锁屏):心跳拉取 + 回执,不新增握手通道
-- `ClientRelease` 服务端缓存的安装包:教室机器没有外网也能更新
-- 在线判定看 `lastHeartbeatAt`(心跳 60 秒一次,窗口见 shared 的 CLASS_HELPER_ONLINE_WINDOW_MS)。
--
-- 四、通知按天归档(需求:「通知发布按天归档」)
-- `Notification.assignDate` 与 `Homework.assignDate` 同口径(本地日期 YYYY-MM-DD,不带时区)。
-- 回填:历史数据按 createdAt 的**本地日期**补写,避免归档页出现一堆"无日期"的旧通知。
--
-- 五、关怀模式 `User.careMode`(需求:「关怀模式(部分老师年纪大使用不习惯)」)。
--
-- 注意:本仓库的 SQLite / MySQL 双库约束是「只用 String / Int / Float / Boolean / DateTime」,
-- 因此上面这些"枚举"(指令类型、审计状态)都是 String + 服务端 zod 校验。
-- AlterTable
ALTER TABLE "User" ADD COLUMN "careMode" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "assignDate" TEXT NOT NULL DEFAULT '';
-- 历史通知按本地日期回填(date() 解不了的值兜底成"今天",避免留空)
UPDATE "Notification" SET "assignDate" = date("createdAt", 'localtime') WHERE "assignDate" = '';
UPDATE "Notification" SET "assignDate" = date('now', 'localtime') WHERE "assignDate" = '';
-- CreateTable
CREATE TABLE "PermissionGroup" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"builtin" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "PermissionGroupItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"groupId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PermissionGroupItem_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "PermissionGroup" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "UserPermissionGroup" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserPermissionGroup_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "UserPermissionGroup_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "PermissionGroup" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AppSetting" (
"key" TEXT NOT NULL PRIMARY KEY,
"value" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "ClientDevice" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT,
"deviceKey" TEXT NOT NULL,
"name" TEXT NOT NULL DEFAULT '',
"clientVersion" TEXT NOT NULL DEFAULT '',
"platform" TEXT NOT NULL DEFAULT '',
"autoUpdate" BOOLEAN NOT NULL DEFAULT true,
"updateTargetVersion" TEXT NOT NULL DEFAULT '',
"pendingVersion" TEXT NOT NULL DEFAULT '',
"lastHeartbeatAt" DATETIME,
"lastIp" TEXT NOT NULL DEFAULT '',
"stateNote" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "ClientDevice_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ClientCommand" (
"id" TEXT NOT NULL PRIMARY KEY,
"deviceId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"payload" TEXT NOT NULL DEFAULT '',
"createdBy" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME,
"ackedAt" DATETIME,
"result" TEXT NOT NULL DEFAULT '',
"cancelledAt" DATETIME,
CONSTRAINT "ClientCommand_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "ClientDevice" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ClientRelease" (
"id" TEXT NOT NULL PRIMARY KEY,
"version" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"sha256" TEXT NOT NULL DEFAULT '',
"size" INTEGER NOT NULL DEFAULT 0,
"notes" TEXT NOT NULL DEFAULT '',
"createdBy" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "AiAudit" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"prompt" TEXT NOT NULL,
"intent" TEXT NOT NULL DEFAULT '',
"status" TEXT NOT NULL DEFAULT 'pending',
"message" TEXT NOT NULL DEFAULT '',
"rawReply" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "PermissionGroup_name_key" ON "PermissionGroup"("name");
-- CreateIndex
CREATE UNIQUE INDEX "PermissionGroupItem_groupId_key_key" ON "PermissionGroupItem"("groupId", "key");
-- CreateIndex
CREATE INDEX "PermissionGroupItem_groupId_idx" ON "PermissionGroupItem"("groupId");
-- CreateIndex
CREATE UNIQUE INDEX "UserPermissionGroup_userId_groupId_key" ON "UserPermissionGroup"("userId", "groupId");
-- CreateIndex
CREATE INDEX "UserPermissionGroup_userId_idx" ON "UserPermissionGroup"("userId");
-- CreateIndex
CREATE INDEX "UserPermissionGroup_groupId_idx" ON "UserPermissionGroup"("groupId");
-- CreateIndex
CREATE UNIQUE INDEX "ClientDevice_deviceKey_key" ON "ClientDevice"("deviceKey");
-- CreateIndex
CREATE INDEX "ClientDevice_classId_idx" ON "ClientDevice"("classId");
-- CreateIndex
CREATE INDEX "ClientDevice_lastHeartbeatAt_idx" ON "ClientDevice"("lastHeartbeatAt");
-- CreateIndex
CREATE INDEX "ClientCommand_deviceId_ackedAt_idx" ON "ClientCommand"("deviceId", "ackedAt");
-- CreateIndex
CREATE INDEX "ClientCommand_createdAt_idx" ON "ClientCommand"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "ClientRelease_version_key" ON "ClientRelease"("version");
-- CreateIndex
CREATE INDEX "AiAudit_userId_createdAt_idx" ON "AiAudit"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "Notification_classId_assignDate_idx" ON "Notification"("classId", "assignDate");
+180
View File
@@ -43,6 +43,8 @@ model User {
role String @default("TEACHER")
/// 手机号(教师联系方式,可空)
phone String @default("")
/// 关怀模式(大字版界面):本人开关,Web 端与客户端各记一份
careMode Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -52,6 +54,8 @@ model User {
courses Course[]
createdHomeworks Homework[] @relation("HomeworkCreator")
createdNotices Notification[] @relation("NotificationCreator")
/// 分配的权限组;**没有任何分组时**回落角色默认模板(见 shared 的 ROLE_DEFAULT_PERMISSIONS)
permissionGroups UserPermissionGroup[]
@@index([role])
}
@@ -240,6 +244,7 @@ model Class {
timeLayouts TimeLayout[]
integrationDevices IntegrationDevice[]
classIslandPushes ClassIslandPush[]
clientDevices ClientDevice[]
@@index([teacherId])
@@index([enrollmentYear])
@@ -353,6 +358,9 @@ model Notification {
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
@@ -362,6 +370,7 @@ model Notification {
reads NotificationRead[]
@@index([classId, createdAt])
@@index([classId, assignDate])
}
/// 通知已读记录
@@ -516,3 +525,174 @@ model ClassIslandPush {
@@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])
}
+37
View File
@@ -70,6 +70,17 @@ function createRandom(seed: number): () => number {
async function resetDatabase(): Promise<void> {
// 顺序敏感:先删子表,再删主表
// 权限组 / 客户端设备 / 远端指令 / 安装包缓存 / 大模型审计(2026-10-10 新增表)
// 注意:ClientDevice.classId 是 SetNull,不显式删会留下"孤儿设备"(班级已删、设备还在)
await prisma.clientCommand.deleteMany();
await prisma.clientDevice.deleteMany();
await prisma.clientRelease.deleteMany();
await prisma.aiAudit.deleteMany();
await prisma.userPermissionGroup.deleteMany();
await prisma.permissionGroupItem.deleteMany();
await prisma.permissionGroup.deleteMany();
// 全局设置(大模型配置 / 开发者模式)不属于业务数据,但演示库重置时一并清掉,保证可复现
await prisma.appSetting.deleteMany();
await prisma.grade.deleteMany();
await prisma.homeworkStatus.deleteMany();
await prisma.homework.deleteMany();
@@ -117,6 +128,32 @@ async function main(): Promise<void> {
data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash },
});
// 内置权限组示例:**没有分配给任何账号**,所以不影响默认行为(演示「创建权限组」这条需求)。
// 一旦把某个老师加进组,他的生效权限就**只由组的并集决定**(可以比角色默认更少 ⇒ 也能收回权限)。
await prisma.permissionGroup.create({
data: {
name: '教务协作(示例)',
description: '可查看班级与课表、发布作业与通知、录入成绩;不能创建班级或管理教师账号',
builtin: true,
items: {
create: [
'class:read',
'schedule:read',
'homework:read',
'homework:write',
'notification:read',
'notification:publish',
'grade:read',
'grade:write',
'dashboard:read',
'term:read',
'client:read',
'ai:use',
].map((key) => ({ key })),
},
},
});
// 班级账号:班级码(ClassHelper 班级端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
//
// 称呼按「入学年份 + 班号」生成:2026 级是高一(当前学年 2026-2027),2025 级是高二。