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

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/server",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module",
@@ -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 级是高二。
@@ -68,6 +68,18 @@ const COLUMNS = [
sql: `ALTER TABLE "User" ADD COLUMN "phone" TEXT NOT NULL DEFAULT ''`,
note: '教师手机号',
},
{
table: 'User',
column: 'careMode',
sql: `ALTER TABLE "User" ADD COLUMN "careMode" BOOLEAN NOT NULL DEFAULT false`,
note: '关怀模式(大字版界面)',
},
{
table: 'Notification',
column: 'assignDate',
sql: `ALTER TABLE "Notification" ADD COLUMN "assignDate" TEXT NOT NULL DEFAULT ''`,
note: '通知所属日期(本地日期 YYYY-MM-DD,按天归档用)',
},
];
/**
+782 -9
View File
@@ -167,9 +167,35 @@ async function main() {
method: 'POST',
body: { username: 'teacher2', password: 'teacher123' },
});
const teacher2Token = teacher2Login.payload?.data?.token;
const teacher2Id = teacher2Login.payload?.data?.user?.id;
let teacher2Token = teacher2Login.payload?.data?.token;
let teacher2Id = teacher2Login.payload?.data?.user?.id;
const teacher1ClassIds = new Set(classes.map((item) => item.id));
// 演示库里的密码可能被人工改过(本机 teacher1 就有被改成 123456 的情况):登录失败时由管理员
// 重置为验证环境已知值再重登。**teacher2 是"科任老师/班级隔离"整组用例的主体**,
// 上一版漏了这条兜底,密码一改后面整片用例都只能跑空分支。
if (!teacher2Token) {
const staffList = await api('/teachers', { token: adminToken });
const fallbackTeacher2Id =
teacher2Id ?? (staffList.payload?.data ?? []).find((item) => item.username === 'teacher2')?.id;
if (fallbackTeacher2Id) {
await api(`/teachers/${fallbackTeacher2Id}/reset-password`, {
method: 'POST',
token: adminToken,
body: { newPassword: 'e2e-teacher2-123456' },
});
const retryTeacher2 = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher2', password: 'e2e-teacher2-123456' },
});
teacher2Token = retryTeacher2.payload?.data?.token;
teacher2Id = retryTeacher2.payload?.data?.user?.id;
record(
'教师 teacher2 密码被改过,已由管理员重置为验证环境已知值(登录环境自愈)',
Boolean(teacher2Token),
`status=${retryTeacher2.status}`,
);
}
}
const teacher2Classes = (await api('/classes', { token: teacher2Token })).payload?.data ?? [];
/** 本次验证临时创建的班级(收尾统一删除,不污染演示数据) */
const probeClassIds = [];
@@ -1191,6 +1217,13 @@ async function main() {
if (subjectCourse) {
const otherCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken }))
.payload?.data?.find((item) => item.teacherId !== teacher2Id);
// 先确认"别人任教的科目"这个前提存在:缺了它,下面那条用例会因为"没带科目"
// 而被拒(403),看着是绿的,其实没验到"非任教科目"这条规则。
record(
'存在"别人任教科目"用于越权校验',
Boolean(otherCourse?.id),
`科目=${otherCourse?.name ?? '-'}`,
);
const foreignHomework = await api('/homeworks', {
method: 'POST',
token: teacher2Token,
@@ -1285,6 +1318,641 @@ async function main() {
}
}
// ---------------------------------------------------------------- 6.5 通知按天归档 + 批量发布
{
const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/;
// 按天归档:通知也有"所属日期"(assignDate,本地日期),晚间发布不会被 UTC 切片算到第二天
const daysRes = await api(`/notifications/days?classId=${classId}`, { token: teacherToken });
const days = daysRes.payload?.data?.days ?? [];
record(
'通知按天归档 GET /notifications/days(按所属日期汇总条数)',
daysRes.status === 200 &&
days.length > 0 &&
days.every((item) => DAY_KEY.test(String(item.date)) && item.count > 0),
`status=${daysRes.status} 天数=${days.length} 样例=${JSON.stringify(days[0] ?? null)}`,
);
const creatorsRes = await api(`/notifications/creators?classId=${classId}`, { token: teacherToken });
const creators = creatorsRes.payload?.data?.creators ?? [];
record(
'通知按发布人归档 GET /notifications/creators(带名字与条数)',
creatorsRes.status === 200 &&
creators.length > 0 &&
creators.every((item) => item.id && item.name && item.count > 0),
`status=${creatorsRes.status} 发布人=${creators.length} 样例=${JSON.stringify(creators[0] ?? null)}`,
);
// 按天筛选:返回的每条通知 assignDate 必须等于筛选的日期
const targetDay = days[0]?.date;
if (targetDay) {
const byDay = await api(`/notifications?classId=${classId}&date=${targetDay}`, { token: teacherToken });
const rows = byDay.payload?.data ?? [];
record(
'通知按天筛选(?date=)只返回该天的通知',
byDay.status === 200 && rows.length > 0 && rows.every((item) => item.assignDate === targetDay),
`date=${targetDay} 条数=${rows.length}`,
);
}
const badDay = await api(`/notifications/days?classId=${classId}&from=2026-13-45`, { token: teacherToken });
record('通知归档接口拒绝非法日期(422)', badDay.status === 422, `status=${badDay.status}`);
// 批量发布通知:一次提交多个班,返回逐班结果
const bulkClassIds = foreignClass ? [classId, foreignClass.id] : [classId];
const bulkNotice = await api('/notifications/bulk', {
method: 'POST',
token: adminToken,
body: {
classIds: bulkClassIds,
title: `批量发布回归 ${Date.now()}`,
content: '批量发布通知用例(管理员对多个班级一次发布)',
priority: 'NORMAL',
},
});
const bulkNoticeData = bulkNotice.payload?.data;
record(
'批量发布通知 POST /notifications/bulk(逐班结果)',
bulkNotice.status === 201 &&
bulkNoticeData?.total === bulkClassIds.length &&
bulkNoticeData?.succeeded === bulkClassIds.length &&
(bulkNoticeData?.results ?? []).every((item) => item.ok && item.id),
`status=${bulkNotice.status} total=${bulkNoticeData?.total} 成功=${bulkNoticeData?.succeeded}`,
);
for (const item of bulkNoticeData?.results ?? []) {
if (item.id) await api(`/notifications/${item.id}`, { method: 'DELETE', token: adminToken });
}
if (foreignClass) {
// 教师批量发到"别人的班":那个班逐班失败,自己的班照常发布,整批不能变成 500
const mixed = await api('/notifications/bulk', {
method: 'POST',
token: teacherToken,
body: {
classIds: [classId, foreignClass.id],
title: `批量越权回归 ${Date.now()}`,
content: '其中一个班没有发布权限',
priority: 'NORMAL',
},
});
const mixedData = mixed.payload?.data;
const denied = (mixedData?.results ?? []).find((item) => item.classId === foreignClass.id);
record(
'批量发布通知:无权限的班逐班失败、有权限的班照常发布',
mixed.status === 201 && mixedData?.succeeded === 1 && denied?.ok === false,
`status=${mixed.status} 成功=${mixedData?.succeeded} 失败code=${denied?.code ?? '-'} 失败原因=${denied?.message ?? '-'}`,
);
for (const item of mixedData?.results ?? []) {
if (item.id) await api(`/notifications/${item.id}`, { method: 'DELETE', token: teacherToken });
}
// 批量布置作业:跨班按**科目名称**匹配课程(课程是班级内的,不可能共用一个 courseId)
const bulkHomework = await api('/homeworks/bulk', {
method: 'POST',
token: adminToken,
body: {
classIds: [classId, foreignClass.id],
subjectName: '语文',
title: `批量布置回归 ${Date.now()}`,
content: '批量布置作业用例(按科目名称跨班匹配)',
},
});
const bulkHomeworkData = bulkHomework.payload?.data;
record(
'批量布置作业 POST /homeworks/bulk(逐班结果 + 按科目名称匹配课程)',
bulkHomework.status === 201 &&
bulkHomeworkData?.total === 2 &&
(bulkHomeworkData?.results ?? []).every((item) => typeof item.ok === 'boolean'),
`status=${bulkHomework.status} 成功=${bulkHomeworkData?.succeeded} 失败=${bulkHomeworkData?.failed} ` +
`逐班=${JSON.stringify((bulkHomeworkData?.results ?? []).map((item) => item.code ?? 'ok'))}`,
);
for (const item of bulkHomeworkData?.results ?? []) {
if (item.id) await api(`/homeworks/${item.id}`, { method: 'DELETE', token: adminToken });
}
}
}
// ---------------------------------------------------------------- 6.6 权限组
{
const meTeacher = await api('/permissions/me', { token: teacherToken });
const meTeacherData = meTeacher.payload?.data;
const meAdmin = await api('/permissions/me', { token: adminToken });
const meAdminData = meAdmin.payload?.data;
record(
'当前账号生效权限 GET /permissions/me(教师有 homework:write、无 permission:manage)',
meTeacher.status === 200 &&
Array.isArray(meTeacherData?.permissions) &&
meTeacherData.permissions.includes('homework:write') &&
!meTeacherData.permissions.includes('permission:manage'),
`status=${meTeacher.status} 权限数=${(meTeacherData?.permissions ?? []).length} ` +
`fromGroups=${meTeacherData?.fromGroups}`,
);
record(
'管理员生效权限是全集(含 permission:manage 与 remote:control)',
meAdmin.status === 200 &&
(meAdminData?.permissions ?? []).includes('permission:manage') &&
(meAdminData?.permissions ?? []).includes('remote:control'),
`status=${meAdmin.status} 权限数=${(meAdminData?.permissions ?? []).length}`,
);
// 权限键目录是 permission:manage 专属:教师 403、管理员拿到整棵树
const catalogDenied = await api('/permissions/catalog', { token: teacherToken });
const catalog = await api('/permissions/catalog', { token: adminToken });
const catalogData = catalog.payload?.data;
record(
'权限目录 GET /permissions/catalog(教师 403 / 管理员拿到 keys+categories+defaultRoles)',
catalogDenied.status === 403 &&
catalog.status === 200 &&
Array.isArray(catalogData?.keys) &&
catalogData.keys.includes('homework:write') &&
Array.isArray(catalogData?.categories) &&
typeof catalogData?.defaultRoles === 'object' &&
catalogData.defaultRoles !== null,
`教师=${catalogDenied.status} 管理员=${catalog.status} 键数=${(catalogData?.keys ?? []).length} ` +
`分类数=${(catalogData?.categories ?? []).length}`,
);
// 权限组可以比角色默认**更少**:只给 dashboard:read 的组分配给 teacher2 后,他只剩这一个键
const groupName = `e2e-只读-${Date.now().toString().slice(-6)}`;
const createdGroup = await api('/permissions/groups', {
method: 'POST',
token: adminToken,
body: { name: groupName, description: '验证用临时权限组(收尾删除)', keys: ['dashboard:read'] },
});
const permissionGroupId = createdGroup.payload?.data?.id;
// 成员列表是"账号 id"的权威来源:登录响应里的 user.id 只是会话用户,
// 权限页的成员 id 要按 username 从 GET /permissions/users 取(管理员本人也要在这里找)
const membersRes = await api('/permissions/users', { token: adminToken });
const members = membersRes.payload?.data ?? [];
const teacher2Member = members.find((item) => item.username === 'teacher2') ?? null;
const adminMember = members.find((item) => item.username === 'admin') ?? null;
// teacher2 的密码按脚本既有做法自愈(本机可能被人工改过),失败就重置成已知值再登
const teacher2Password = 'teacher123';
let teacher2Auth = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher2', password: teacher2Password },
});
if (teacher2Auth.status !== 200 && teacher2Member) {
await api(`/teachers/${teacher2Member.id}/reset-password`, {
method: 'POST',
token: adminToken,
body: { newPassword: teacher2Password },
});
teacher2Auth = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher2', password: teacher2Password },
});
}
const permissionTestToken = teacher2Auth.payload?.data?.token;
const assignLess = permissionGroupId
? await api(`/permissions/users/${teacher2Member?.id}/groups`, {
method: 'PUT',
token: adminToken,
body: { groupIds: [permissionGroupId] },
})
: { status: 0, payload: null };
const meLess = permissionTestToken
? await api('/permissions/me', { token: permissionTestToken })
: { status: 0, payload: null };
const teacher2LessPermissions = meLess.payload?.data?.permissions ?? [];
record(
'权限组可以比角色默认更少(只给 dashboard:read 的组 → 生效权限恰好只有这一个键)',
Boolean(permissionGroupId) &&
assignLess.status === 200 &&
teacher2LessPermissions.length === 1 &&
teacher2LessPermissions[0] === 'dashboard:read',
`建组=${createdGroup.status} 分配=${assignLess.status} 生效权限=${JSON.stringify(teacher2LessPermissions)}`,
);
const assignDefault = await api(`/permissions/users/${teacher2Member?.id}/groups`, {
method: 'PUT',
token: adminToken,
body: { groupIds: [] },
});
const meReset = permissionTestToken
? await api('/permissions/me', { token: permissionTestToken })
: { status: 0, payload: null };
const teacher2ResetPermissions = meReset.payload?.data?.permissions ?? [];
record(
'清空权限组后回到角色默认模板(PUT /users/:id/groups 传空数组 → 含 homework:write)',
assignDefault.status === 200 &&
teacher2ResetPermissions.includes('homework:write') &&
!teacher2ResetPermissions.includes('permission:manage'),
`status=${assignDefault.status} 生效权限数=${teacher2ResetPermissions.length} ` +
`含 homework:write=${teacher2ResetPermissions.includes('homework:write')}`,
);
// 管理员固定拥有全部权限,不接受分配
const assignAdmin = await api(`/permissions/users/${adminMember?.id}/groups`, {
method: 'PUT',
token: adminToken,
body: { groupIds: permissionGroupId ? [permissionGroupId] : [] },
});
record(
'管理员不接受权限组分配(PUT /users/:id/groups → 400)',
Boolean(adminMember) &&
assignAdmin.status === 400 &&
String(assignAdmin.payload?.message ?? '').includes('权限组'),
`status=${assignAdmin.status} 响应=${assignAdmin.payload?.message ?? ''}`,
);
// 内置权限组不可删除:先找一个 builtin 的(**演示库当前没有内置组**,此时只报事实、不算失败)
const groupsList = await api('/permissions/groups', { token: adminToken });
const builtinGroup = (groupsList.payload?.data ?? []).find((item) => item.builtin === true) ?? null;
if (builtinGroup) {
const deleteBuiltin = await api(`/permissions/groups/${builtinGroup.id}`, { method: 'DELETE', token: adminToken });
record(
'内置权限组不可删除(DELETE /permissions/groups/:id → 400)',
deleteBuiltin.status === 400,
`内置组=${builtinGroup.name} status=${deleteBuiltin.status} 响应=${deleteBuiltin.payload?.message ?? ''}`,
);
} else {
record(
'内置权限组不可删除(DELETE /permissions/groups/:id → 400)',
true,
`跳过:当前库里没有任何 builtin=true 的权限组(GET /permissions/groups 返回 ${(groupsList.payload?.data ?? []).length} 个,种子里的「教务协作(示例)」不在库里)`,
);
}
// 收尾:teacher2 必须回到角色默认(否则后面所有以他为科任老师的用例全被权限组污染),临时组删掉
await api(`/permissions/users/${teacher2Member?.id}/groups`, {
method: 'PUT',
token: adminToken,
body: { groupIds: [] },
});
if (permissionGroupId) {
await api(`/permissions/groups/${permissionGroupId}`, { method: 'DELETE', token: adminToken });
}
const groupsAfter = await api('/permissions/groups', { token: adminToken });
record(
'权限组用例收尾:teacher2 分组已清空、临时权限组已删除',
!(groupsAfter.payload?.data ?? []).some((item) => item.id === permissionGroupId),
`剩余权限组=${(groupsAfter.payload?.data ?? []).length}`,
);
}
// ---------------------------------------------------------------- 6.7 客户端设备与远控
{
const deviceKey = `e2e-device-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const heartbeatBase = {
deviceKey,
name: 'E2E 测试机',
clientVersion: '1.0.0',
platform: 'win32',
stateNote: '待机',
};
// 心跳不强求权限键(教室端会话权限集为空),但**必须登录**:没有 JWT 一律 401
const heartbeatNoAuth = await api('/clients/heartbeat', { method: 'POST', body: heartbeatBase });
const heartbeat = await api('/clients/heartbeat', { method: 'POST', token: adminToken, body: heartbeatBase });
const heartbeatData = heartbeat.payload?.data;
record(
'客户端心跳 POST /clients/heartbeat(未登录 401 / 登录后 200 且返回 deviceId 与 command 字段)',
heartbeatNoAuth.status === 401 &&
heartbeat.status === 200 &&
Boolean(heartbeatData?.deviceId) &&
'command' in (heartbeatData ?? {}),
`未登录=${heartbeatNoAuth.status} 登录=${heartbeat.status} deviceId=${heartbeatData?.deviceId ?? '-'} ` +
`command=${JSON.stringify(heartbeatData?.command ?? null)}`,
);
// 越权:remote:control 默认只有管理员有
const teacherCommandDenied = await api('/clients/commands', {
method: 'POST',
token: teacherToken,
body: { deviceIds: [heartbeatData?.deviceId ?? 'none'], kind: 'lock', note: '越权用例' },
});
record(
'远控命令越权被拒(教师无 remote:control → 403)',
teacherCommandDenied.status === 403,
`status=${teacherCommandDenied.status} 响应=${teacherCommandDenied.payload?.message ?? ''}`,
);
// 设备列表:刚心跳的这台要在线
const devices = await api('/clients/devices', { token: adminToken });
const device = (devices.payload?.data ?? []).find((item) => item.deviceKey === deviceKey) ?? null;
record(
'设备列表 GET /clients/devices(刚心跳的设备在线)',
devices.status === 200 && Boolean(device) && device.online === true,
`status=${devices.status} 设备=${device?.name ?? '未找到'} online=${device?.online}`,
);
// 下发锁屏命令(带备注)→ 下一次心跳必须带回这条命令
const command = await api('/clients/commands', {
method: 'POST',
token: adminToken,
body: { deviceIds: [device?.id ?? heartbeatData?.deviceId ?? 'none'], kind: 'lock', note: 'E2E 远控用例' },
});
const heartbeatWithCommand = await api('/clients/heartbeat', {
method: 'POST',
token: adminToken,
body: { ...heartbeatBase, stateNote: '待机' },
});
const delivered = heartbeatWithCommand.payload?.data?.command;
record(
'远控命令下发 POST /clients/commands(lock)并在下次心跳送达(kind=lock)',
command.status === 201 && Boolean(delivered?.id) && delivered?.kind === 'lock',
`下发=${command.status} 送达=${JSON.stringify(delivered ?? null)}`,
);
const ackBody = { deviceKey, ok: true, result: 'e2e' };
const ackFirst = await api(`/clients/commands/${delivered?.id}/ack`, {
method: 'POST',
token: adminToken,
body: ackBody,
});
// 幂等:客户端重试回执不报错、也不覆盖第一次结果
const ackSecond = await api(`/clients/commands/${delivered?.id}/ack`, {
method: 'POST',
token: adminToken,
body: { deviceKey, ok: false, result: '重试不应覆盖结果' },
});
record(
'命令回执 POST /clients/commands/:id/ack(首次 200,重复回执幂等仍 200)',
ackFirst.status === 200 && ackSecond.status === 200,
`首次=${ackFirst.status}(${ackFirst.payload?.data?.result ?? '-'}) 重复=${ackSecond.status}(${ackSecond.payload?.data?.result ?? '-'})`,
);
const devicesAfterAck = await api('/clients/devices', { token: adminToken });
const ackedDevice = (devicesAfterAck.payload?.data ?? []).find((item) => item.deviceKey === deviceKey) ?? null;
record(
'回执后设备不再有待执行命令(pendingCommand 为空)',
Boolean(ackedDevice) && ackedDevice.pendingCommand === null,
`pendingCommand=${JSON.stringify(ackedDevice?.pendingCommand ?? null)}`,
);
// 归属校验:别人拿自己的 deviceKey 回执同一 id 必须被拒
const otherDeviceKey = `${deviceKey}-other`;
const otherHeartbeat = await api('/clients/heartbeat', {
method: 'POST',
token: adminToken,
body: { ...heartbeatBase, deviceKey: otherDeviceKey, name: 'E2E 另一台测试机' },
});
const otherDeviceId = otherHeartbeat.payload?.data?.deviceId ?? null;
const ackWrongDevice = await api(`/clients/commands/${delivered?.id}/ack`, {
method: 'POST',
token: adminToken,
body: { deviceKey: otherDeviceKey, ok: true, result: 'e2e-other' },
});
record(
'回执归属校验(别的 deviceKey 回执同一命令 → 403)',
ackWrongDevice.status === 403,
`status=${ackWrongDevice.status} code=${ackWrongDevice.payload?.code ?? '-'} ` +
`响应=${ackWrongDevice.payload?.message ?? ''}`,
);
// 收尾:两台测试机都清掉(否则会长期挂在设备列表里)
const deleteTestDevice = device
? await api(`/clients/devices/${device.id}`, { method: 'DELETE', token: adminToken })
: { status: 0, payload: null };
const deleteOtherDevice = otherDeviceId
? await api(`/clients/devices/${otherDeviceId}`, { method: 'DELETE', token: adminToken })
: { status: 0, payload: null };
const deviceAfterDelete = await api('/clients/devices', { token: adminToken });
const leftoverKeys = (deviceAfterDelete.payload?.data ?? [])
.filter((item) => item.deviceKey === deviceKey || item.deviceKey === otherDeviceKey)
.map((item) => item.deviceKey);
record(
'客户端设备用例收尾:两台测试机都已移除',
deleteTestDevice.status === 200 && deleteOtherDevice.status === 200 && leftoverKeys.length === 0,
`删除测试机=${deleteTestDevice.status} 删除另一台=${deleteOtherDevice.status} ` +
`残留=${leftoverKeys.length ? leftoverKeys.join(',') : '无'}`,
);
}
// ---------------------------------------------------------------- 6.8 客户端安装包
{
const releases = await api('/update/client/releases', { token: adminToken });
record(
'客户端安装包列表 GET /update/client/releases',
releases.status === 200 && Array.isArray(releases.payload?.data),
`status=${releases.status} 版本数=${(releases.payload?.data ?? []).length}`,
);
// 登记只写元数据,**但要求磁盘上真的有这个版本的安装包**:不存在的版本必须被拒(400)
const fakeVersion = '9.9.9-e2e';
const registerMissing = await api('/update/client/releases', {
method: 'POST',
token: adminToken,
body: { version: fakeVersion },
});
const releasesAfterRegister = await api('/update/client/releases', { token: adminToken });
record(
'登记不存在的客户端安装包版本被拒(400,且不会落库)',
registerMissing.status === 400 &&
!(releasesAfterRegister.payload?.data ?? []).some((item) => item.version === fakeVersion),
`status=${registerMissing.status} code=${registerMissing.payload?.code ?? '-'} ` +
`响应=${registerMissing.payload?.message ?? ''}`,
);
// 版本白名单:含路径分隔符的版本号连 schema 都过不去
const badVersion = await api('/update/client/releases', {
method: 'POST',
token: adminToken,
body: { version: '../evil' },
});
record(
'版本号白名单(POST /update/client/releases 传 ../evil → 400/422)',
badVersion.status === 400 || badVersion.status === 422,
`status=${badVersion.status} code=${badVersion.payload?.code ?? '-'} ` +
`响应=${badVersion.payload?.message ?? ''}`,
);
// 下载接口本身不挂权限键,但**要登录**:不带 token 会被 401 挡在鉴权层
const downloadNoAuth = await api(`/update/client/download/${encodeURIComponent(fakeVersion)}`);
const downloadMissing = await api(`/update/client/download/${encodeURIComponent(fakeVersion)}`, {
token: adminToken,
});
record(
'下载不存在的客户端安装包(未登录 401 / 已登录 404)',
downloadNoAuth.status === 401 && downloadMissing.status === 404,
`未登录=${downloadNoAuth.status} 已登录=${downloadMissing.status} ` +
`响应=${downloadMissing.payload?.message ?? ''}`,
);
}
// ---------------------------------------------------------------- 6.9 大模型助手(一律 mock,绝不联网)
{
const aiSettingsDenied = await api('/ai/settings', { token: teacherToken });
const aiSettings = await api('/ai/settings', { token: adminToken });
const aiSettingsData = aiSettings.payload?.data ?? {};
const aiSettingsText = JSON.stringify(aiSettingsData);
record(
'大模型设置 GET /ai/settings(教师 403 / 管理员 200,且响应里没有任何密钥字段)',
aiSettingsDenied.status === 403 &&
aiSettings.status === 200 &&
typeof aiSettingsData.enabled === 'boolean' &&
'provider' in aiSettingsData &&
'baseUrl' in aiSettingsData &&
'model' in aiSettingsData &&
'hasApiKey' in aiSettingsData &&
'keyHint' in aiSettingsData &&
'systemPromptExtra' in aiSettingsData &&
!aiSettingsText.includes('apiKey'),
`教师=${aiSettingsDenied.status} 管理员=${aiSettings.status} 字段=${Object.keys(aiSettingsData).join(',')}`,
);
// 助手没开启时 /interpret 会 400:先开启,且**强制走离线 mock 解析**(绝不联网)
const enableAi = await api('/ai/settings', {
method: 'PUT',
token: adminToken,
body: { enabled: true, provider: 'mock' },
});
record(
'开启大模型助手并固定为离线 mock(PUT /ai/settings)',
enableAi.status === 200 && enableAi.payload?.data?.enabled === true && enableAi.payload?.data?.provider === 'mock',
`status=${enableAi.status} enabled=${enableAi.payload?.data?.enabled} provider=${enableAi.payload?.data?.provider}`,
);
const promptText = '给2026级1班布置作业,把卷子写完';
const interpret = await api('/ai/interpret', {
method: 'POST',
token: teacherToken,
body: { text: promptText },
});
const interpretData = interpret.payload?.data;
record(
'助手解析布置作业(POST /ai/interpret → create_homework,需确认、班级非空、有预览)',
interpret.status === 200 &&
interpretData?.action === 'create_homework' &&
interpretData?.needConfirm === true &&
Boolean(interpretData?.params?.classId) &&
Boolean(interpretData?.preview?.summary),
`status=${interpret.status} action=${interpretData?.action} needConfirm=${interpretData?.needConfirm} ` +
`classId=${interpretData?.params?.classId ?? '-'} summary=${interpretData?.preview?.summary ?? '-'}`,
);
const unknownInterpret = await api('/ai/interpret', {
method: 'POST',
token: teacherToken,
body: { text: '今天天气不错' },
});
record(
'助手听不懂时返回 unknown(不猜动作)',
unknownInterpret.status === 200 && unknownInterpret.payload?.data?.action === 'unknown',
`status=${unknownInterpret.status} action=${unknownInterpret.payload?.data?.action}`,
);
const audit = await api('/ai/audit', { token: adminToken });
const auditRows = audit.payload?.data ?? [];
record(
'助手使用审计 GET /ai/audit(能看到刚写入的那句话)',
audit.status === 200 && auditRows.some((item) => item.prompt === promptText),
`status=${audit.status} 记录数=${auditRows.length}`,
);
// 收尾:助手必须还原成关闭(否则别的东西会以为已经配好了模型)
const disableAi = await api('/ai/settings', {
method: 'PUT',
token: adminToken,
body: { enabled: false },
});
record(
'大模型助手用例收尾:enabled 已还原为 false',
disableAi.status === 200 && disableAi.payload?.data?.enabled === false,
`status=${disableAi.status} enabled=${disableAi.payload?.data?.enabled}`,
);
}
// ---------------------------------------------------------------- 6.10 系统设置
{
const systemDenied = await api('/system/settings', { token: teacherToken });
const systemSettings = await api('/system/settings', { token: adminToken });
record(
'系统设置 GET /system/settings(教师 403 / 管理员 200)',
systemDenied.status === 403 &&
systemSettings.status === 200 &&
typeof systemSettings.payload?.data?.developerMode === 'boolean',
`教师=${systemDenied.status} 管理员=${systemSettings.status} ` +
`developerMode=${systemSettings.payload?.data?.developerMode}`,
);
// 开发者模式:开启后错误响应会带上堆栈,所以**必须**还原
const enableDeveloper = await api('/system/settings', {
method: 'PUT',
token: adminToken,
body: { developerMode: true },
});
const readBackDeveloper = await api('/system/settings', { token: adminToken });
const disableDeveloper = await api('/system/settings', {
method: 'PUT',
token: adminToken,
body: { developerMode: false },
});
const readBackDisabled = await api('/system/settings', { token: adminToken });
record(
'开发者模式可开关并回读(PUT /system/settings → GET 一致,随后还原为 false)',
enableDeveloper.status === 200 &&
readBackDeveloper.payload?.data?.developerMode === true &&
disableDeveloper.status === 200 &&
readBackDisabled.payload?.data?.developerMode === false,
`开启=${enableDeveloper.status} 回读=${readBackDeveloper.payload?.data?.developerMode} ` +
`还原=${readBackDisabled.payload?.data?.developerMode}`,
);
const autostart = await api('/system/autostart', { token: adminToken });
const autostartData = autostart.payload?.data;
const enableAutostart = await api('/system/autostart', {
method: 'PUT',
token: adminToken,
body: { enabled: true },
});
record(
'开机自启:开发机不支持(GET supported=false,PUT 拒绝 400)',
autostart.status === 200 &&
autostartData?.supported === false &&
typeof autostartData?.detail === 'string' &&
autostartData.detail.length > 0 &&
enableAutostart.status === 400,
`GET=${autostart.status} supported=${autostartData?.supported} mechanism=${autostartData?.mechanism} ` +
`detail=${autostartData?.detail ?? '-'} PUT=${enableAutostart.status} ` +
`响应=${enableAutostart.payload?.message ?? ''}`,
);
const diagnostics = await api('/system/diagnostics', { token: adminToken });
const diagnosticsText = JSON.stringify(diagnostics.payload?.data ?? {});
record(
'运行诊断 GET /system/diagnostics(200 且不泄漏 apiKey/password/token/secret)',
diagnostics.status === 200 &&
!/apiKey|password|token|secret/i.test(diagnosticsText),
`status=${diagnostics.status} 字段=${Object.keys(diagnostics.payload?.data ?? {}).join(',')}`,
);
}
// ---------------------------------------------------------------- 6.11 批量发布补漏
{
// 一个不存在的班级 + 一个真实班级:单班失败不能把整批带崩(逐班结果,不整批回滚)
const missingClassId = `not-exist-${Date.now().toString(36)}`;
const mixedBulk = await api('/notifications/bulk', {
method: 'POST',
token: adminToken,
body: {
classIds: [missingClassId, classId],
title: `批量失败隔离回归 ${Date.now()}`,
content: '其中一个班不存在,另一个班照常发布',
priority: 'NORMAL',
},
});
const mixedBulkData = mixedBulk.payload?.data;
const missingResult = (mixedBulkData?.results ?? []).find((item) => item.classId === missingClassId);
record(
'批量发布:不存在的班级逐班失败、真实班级照常成功(failed>=1 且 succeeded>=1)',
mixedBulk.status === 201 &&
(mixedBulkData?.failed ?? 0) >= 1 &&
(mixedBulkData?.succeeded ?? 0) >= 1 &&
missingResult?.ok === false,
`status=${mixedBulk.status} total=${mixedBulkData?.total} 成功=${mixedBulkData?.succeeded} ` +
`失败=${mixedBulkData?.failed} 失败code=${missingResult?.code ?? '-'}`,
);
// 清理:只删成功落库的那几条(失败的班没有 id)
for (const item of mixedBulkData?.results ?? []) {
if (item.id) await api(`/notifications/${item.id}`, { method: 'DELETE', token: adminToken });
}
}
// ---------------------------------------------------------------- 7. 权限隔离
if (foreignClass) {
const studentCrossClass = await api(`/classes/${foreignClass.id}`, { token: studentToken });
@@ -1684,6 +2352,76 @@ async function main() {
`status=${gradesByHeadTeacher.status} 跳过=${gradesByHeadTeacher.payload?.data?.skipped} 新增=${gradesByHeadTeacher.payload?.data?.inserted}`,
);
// 8.3.1 科目归属越权回归(修复"非该科科任老师可以布置/导入其他科内容"):
// ① 新建成同名课程必须被拒绝 —— 否则谁都能借"新建科目"把自己写成该科任课老师,
// 后面那道 assertCanManageSubjectContent 的科目门随即失效;
// ② 班主任导入**别人任教科目**的成绩必须逐行拒绝(原先只判到班级这一层)。
{
const guardedCourseName = `越权校验科目${Date.now().toString(36).slice(-5).toUpperCase()}`;
const firstCourse = await api('/courses', {
method: 'POST',
token: adminToken,
body: { classId, name: guardedCourseName },
});
const firstCourseId = firstCourse.payload?.data?.id;
record(
'新建科目成功(用于越权校验)',
firstCourse.status === 201 && Boolean(firstCourseId),
`status=${firstCourse.status} id=${firstCourseId ?? '-'}`,
);
const duplicateCourse = await api('/courses', {
method: 'POST',
token: adminToken,
body: { classId, name: guardedCourseName },
});
record(
'同名课程被拒绝并返回已有课程 id(COURSE_EXISTS)',
duplicateCourse.status === 409 &&
duplicateCourse.payload?.code === 'COURSE_EXISTS' &&
duplicateCourse.payload?.details?.courseId === firstCourseId,
`status=${duplicateCourse.status} code=${duplicateCourse.payload?.code} details=${JSON.stringify(duplicateCourse.payload?.details ?? {})}`,
);
// 这门课的任课老师是**管理员**(course.create 会把创建者写成任课老师):
// 班主任导入它的成绩必须被拒,且不能落库。
const guardedCsv = [
'学号,考试名称,分数,总分,科目',
`${targetStudent?.studentNo ?? '202601'},越权校验考试,88,100,${guardedCourseName}`,
].join(NL);
const guardedImport = await api('/imports/table/commit', {
method: 'POST',
token: teacherToken,
body: {
kind: 'grades',
classId,
fileName: 'guarded.csv',
contentBase64: Buffer.from(`\ufeff${guardedCsv}${NL}`, 'utf8').toString('base64'),
mapping: {
studentNo: '学号',
examName: '考试名称',
score: '分数',
totalScore: '总分',
courseName: '科目',
},
mode: 'append',
},
});
const guardedErrors = guardedImport.payload?.data?.errors ?? [];
record(
'班主任导入他人任教科目成绩被逐行拒绝(不落库)',
guardedImport.status === 200 &&
guardedImport.payload?.data?.inserted === 0 &&
guardedImport.payload?.data?.failed === 1 &&
String(guardedErrors[0]?.message ?? '').includes('不是你任教的科目'),
`新增=${guardedImport.payload?.data?.inserted} 失败=${guardedImport.payload?.data?.failed} 提示=${guardedErrors[0]?.message ?? '-'}`,
);
if (firstCourseId) {
await api(`/courses/${firstCourseId}`, { method: 'DELETE', token: adminToken });
}
}
// 8.4 学生名单导入(仅管理员)+ 数据清理
const importedUsername = `IMP${Date.now().toString(36).slice(-6)}`.toUpperCase();
// 学生导入模板已无"初始密码"列(学生没有账号属性);表头是「学号」
@@ -2433,17 +3171,48 @@ async function main() {
// ---------------------------------------------------------------- 数据库管理(仅管理员)
// 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。
// 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。
// 数据表清单随模型演进增长(2026-10-10 加了权限组 / 客户端管理 8 张表后共 23 张)。
// **不写死总数**:断言"服务端快照枚举 == 这份清单",加模型时只改这一处。
const REQUIRED_DB_TABLES = [
'User',
'ArchivedYear',
'Class',
'Student',
'Course',
'StudentClassTransfer',
'Schedule',
'TimeLayout',
'Homework',
'HomeworkStatus',
'Notification',
'NotificationRead',
'Grade',
'IntegrationDevice',
'ClassIslandPush',
'PermissionGroup',
'PermissionGroupItem',
'UserPermissionGroup',
'AppSetting',
'ClientDevice',
'ClientCommand',
'ClientRelease',
'AiAudit',
];
const dbStatus = await api('/database/status', { token: adminToken });
const dbStatusData = dbStatus.payload?.data ?? {};
const dbStatusTableNames = Array.isArray(dbStatusData.tables)
? dbStatusData.tables.map((item) => item.name)
: [];
record(
'数据库状态(管理员):已连接且返回 15 张表行数',
'数据库状态(管理员):已连接且返回全部数据表行数',
dbStatus.status === 200 &&
dbStatusData.connected === true &&
Array.isArray(dbStatusData.tables) &&
dbStatusData.tables.length === 15 &&
dbStatusData.tables.every((item) => item.count >= 0),
dbStatusData.tables.every((item) => item.count >= 0) &&
dbStatusTableNames.length === REQUIRED_DB_TABLES.length &&
REQUIRED_DB_TABLES.every((name) => dbStatusTableNames.includes(name)),
`provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${
(dbStatusData.tables ?? []).length
dbStatusTableNames.length
}`,
);
@@ -2556,13 +3325,17 @@ async function main() {
} catch {
snapshotParsed = null;
}
const snapshotTableNames = Object.keys(snapshotParsed?.data ?? {});
record(
'导出快照(JSON 格式正确、15 张表、账号数 > 0)',
'导出快照(JSON 格式正确、全部数据表、账号数 > 0)',
dbExport.status === 200 &&
snapshotParsed?.format === 'classhelper-snapshot' &&
Object.keys(snapshotParsed?.data ?? {}).length === 15 &&
snapshotTableNames.length === REQUIRED_DB_TABLES.length &&
REQUIRED_DB_TABLES.every((name) => snapshotTableNames.includes(name)) &&
(snapshotParsed?.counts?.User ?? 0) > 0,
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`,
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 表=${snapshotTableNames.length} 用户=${
snapshotParsed?.counts?.User ?? '-'
}`,
);
// 造一条临时通知 → 快照导入(应被清掉)→ 再造一条 → 从备份恢复(也应被清掉)
+4
View File
@@ -7,6 +7,7 @@ import { env } from './config/env.js';
import { disconnectPrisma, pingDatabase } from './lib/db.js';
import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js';
import { logger } from './lib/logger.js';
import { refreshDeveloperMode } from './lib/settings.js';
import { resolveWebDistDir } from './lib/web-static.js';
import { initRealtime } from './realtime/socket.js';
import { startBackupScheduler } from './modules/database/database.service.js';
@@ -51,6 +52,9 @@ async function prepareDatabase(): Promise<void> {
await prepareDatabase();
// 开发者模式的内存缓存:错误中间件每请求都要问一次,不可能为此查库(见 lib/settings.ts)
await refreshDeveloperMode();
const app = createApp();
const httpServer = createServer(app);
const io = initRealtime(httpServer);
+38
View File
@@ -0,0 +1,38 @@
import type { BulkPublishClassResult, BulkPublishResult } from '@classhelper/shared';
import { ApiError } from './http.js';
import { logger } from './logger.js';
/**
* 批量发布的公共收尾:把单个班级的失败翻译成可直接展示给用户的结果。
*
* 批量发布是**逐班判定**的 —— 某个班没有权限、或正在上课而通知是紧急的,
* 都不该让整批失败:成功的班照常落库并实时推送,失败的班把自己的错误码与中文原因带回来,
* 由 Web 端在结果弹窗里逐行列出。因此这里保留 ApiError 的 code/details 原样透传。
*/
export function toBulkFailure(classId: string, error: unknown, action = '发布'): BulkPublishClassResult {
if (error instanceof ApiError) {
return {
classId,
ok: false,
code: error.code,
message: error.message,
...(error.details === undefined ? {} : { details: error.details as Record<string, unknown> }),
};
}
logger.error(
`批量${action}出现未预期错误:班级=${classId} 原因=${error instanceof Error ? error.message : String(error)}`,
);
return { classId, ok: false, code: 'INTERNAL_ERROR', message: `${action}失败,请稍后重试` };
}
/** 汇总逐班结果(succeeded/failed 由结果本身统计,调用方不必自己数) */
export function summarizeBulk(results: BulkPublishClassResult[]): BulkPublishResult {
const succeeded = results.filter((item) => item.ok).length;
return { total: results.length, succeeded, failed: results.length - succeeded, results };
}
/** 批量目标班级:去重并保持调用方给的顺序(多选控件重复提交时会带重复 id) */
export function normalizeBulkClassIds(classIds: readonly string[]): string[] {
return [...new Set(classIds)];
}
+8
View File
@@ -19,6 +19,14 @@ export type ApiErrorCode =
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'CONFLICT'
/** 课程:该班级已有同名课程(前端应复用 `details.courseId`,而不是另建一门) */
| 'COURSE_EXISTS'
/** 大模型:功能未开启 / 还没配置服务地址与密钥 */
| 'AI_NOT_CONFIGURED'
/** 大模型:调用超时或网络不可达 */
| 'AI_TIMEOUT'
/** 大模型:返回的内容不是可用的结构化动作(已记日志,不把原始回复回传给前端) */
| 'AI_INVALID_OUTPUT'
| 'TOO_MANY_REQUESTS'
/** 上课时间段发布紧急通知需要二次确认(前端据此弹出全屏警告 + 3 秒倒计时) */
| 'URGENT_DURING_CLASS'
+4
View File
@@ -378,6 +378,8 @@ export interface NotificationLike {
priority: string;
createdBy: string;
createdAt: Date;
/** 通知所属日期(YYYY-MM-DD);老库可能为空串,DTO 层按 createdAt 本地日期兜底 */
assignDate?: string;
creator?: TeacherBriefLike | null;
reads?: NotificationReadLike[];
}
@@ -403,6 +405,8 @@ export function toNotificationDto(
priority: item.priority as NotificationPriority,
createdBy: item.createdBy,
createdAt: toIso(item.createdAt),
// 老库兜底:assignDate 为空时按 createdAt 的本地日期回退,前端不必处理空值
assignDate: item.assignDate || dayKeyLocal(item.createdAt),
creator: item.creator ? toTeacherBrief(item.creator) : null,
read: own ? true : false,
readAt: toIsoOrNull(own?.readAt),
+67
View File
@@ -0,0 +1,67 @@
import {
PERMISSION_KEYS,
PERMISSION_LABELS,
ROLE_DEFAULT_PERMISSIONS,
defaultPermissionsForRole,
type PermissionKey,
} from '@classhelper/shared';
import { prisma } from './db.js';
import type { TokenPayload } from './jwt.js';
/**
* 生效权限集合的**唯一加载入口**(权限组功能的全部运行时逻辑都收在这里)。
*
* 语义(与 shared 的 `PERMISSION_DEFAULT_ROLES` 一一对应):
* - `ADMIN`:全集。另外**永远**保留 {@link ADMIN_UNREMOVABLE_PERMISSIONS},避免管理员
* 把自己(或最后一个管理员)锁在权限页之外;
* - `CLASS_DEVICE`(ClassHelper 班级端):**空集,且不受权限组影响** —— 班级端的会话主体是
* 班级而非账号,它的能力由班级维度断言(`canManageSubjectContent(..., { allowClassDevice })`
* 等)决定,给某个班级端"分配权限组"没有意义;
* - 其他账号:**有分组**时取这些组的键并集(因此可以比默认更少 ⇒ 也能用来收回权限);
* **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 `requireRole`)。
*
* 每次请求实时查询(不缓存):权限改动**立即生效**,代价是每请求多一条 `findMany`
* (账号规模很小,这个代价换"改了立刻生效"是值的)。
*/
export async function loadEffectivePermissions(user: TokenPayload): Promise<Set<PermissionKey>> {
const permissions = new Set<PermissionKey>();
if (user.classSession || user.role === 'CLASS_DEVICE') return permissions;
if (user.role === 'ADMIN') {
for (const key of PERMISSION_KEYS) permissions.add(key);
return permissions;
}
const assignments = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
select: { group: { select: { items: { select: { key: true } } } } },
});
if (assignments.length === 0) {
for (const key of defaultPermissionsForRole(user.role)) permissions.add(key);
return permissions;
}
const known = new Set<string>(PERMISSION_KEYS);
for (const assignment of assignments) {
for (const item of assignment.group.items) {
// 库里可能留着已下线的键(旧版本写入的),这里静默忽略,避免整账号权限异常
if (known.has(item.key)) permissions.add(item.key as PermissionKey);
}
}
return permissions;
}
/** 管理员不可被剥夺的权限键(防锁死) */
export const ADMIN_UNREMOVABLE_PERMISSIONS: readonly PermissionKey[] = ['permission:manage', 'system:manage'];
/** 角色默认模板(供权限页展示"没分组时会是什么") */
export function roleDefaultPermissions(role: TokenPayload['role']): readonly PermissionKey[] {
return ROLE_DEFAULT_PERMISSIONS[role];
}
/** 把权限键翻译成中文提示(403 文案与权限页共用) */
export function permissionLabel(key: PermissionKey): string {
return PERMISSION_LABELS[key];
}
+37
View File
@@ -0,0 +1,37 @@
import crypto from 'node:crypto';
import { env } from '../config/env.js';
/**
* 全局设置里"密钥类"值的加解密(目前只有大模型的 API Key)。
*
* 为什么不用明文存 `AppSetting`:那张表会进备份/导出/跨库迁移的快照,
* 明文密钥等于把它写进了每一次备份。这里用 **AES-256-GCM**,密钥由 `JWT_SECRET`
* 经 scrypt 派生(不需要新的环境变量),密文带 `v1:` 前缀便于将来轮换算法。
*
* 代价:`JWT_SECRET` 一改,旧密文就解不开了 —— 这是有意的(换密钥本来就该重新填密钥),
* `decryptSecret()` 解不开时返回空串,AI 设置页会显示"未配置"。
*/
const KEY = crypto.scryptSync(env.jwtSecret, 'classhelper-app-setting', 32);
export function encryptSecret(plain: string): string {
if (!plain) return '';
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
const encrypted = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
return `v1:${iv.toString('base64')}:${cipher.getAuthTag().toString('base64')}:${encrypted.toString('base64')}`;
}
export function decryptSecret(payload: string): string {
if (!payload.startsWith('v1:')) return '';
const [, ivBase64 = '', tagBase64 = '', dataBase64 = ''] = payload.split(':');
try {
const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, Buffer.from(ivBase64, 'base64'));
decipher.setAuthTag(Buffer.from(tagBase64, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(dataBase64, 'base64')),
decipher.final(),
]).toString('utf8');
} catch {
return '';
}
}
+77
View File
@@ -0,0 +1,77 @@
import { prisma } from './db.js';
/**
* 全局设置(`AppSetting` 表的唯一读写入口)。
*
* 值一律存字符串:布尔存 `'true'`/`'false'`,复杂结构存 JSON 文本。
* 键名集中在这里,避免各模块手写字符串导致拼错后"设置静默不生效"。
*/
export const SETTING_KEYS = {
/** 服务端开发者模式(仅 ADMIN 可改):错误响应带堆栈、日志降到 debug 级 */
developerMode: 'system.developerMode',
/** 大模型功能开关与配置(见 modules/ai) */
aiEnabled: 'ai.enabled',
/** 服务端解析器的实现:openai=真接口 / mock=离线确定性结果(自动化验收用) */
aiProvider: 'ai.provider',
aiBaseUrl: 'ai.baseUrl',
aiModel: 'ai.model',
/** 加密后的 API Key(见 lib/secrets.ts;接口只进不出) */
aiApiKey: 'ai.apiKey',
/** 追加到系统提示词末尾的学校自定义要求(可空) */
aiSystemPromptExtra: 'ai.systemPromptExtra',
} as const;
export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];
export async function getSetting(key: SettingKey): Promise<string> {
const row = await prisma.appSetting.findUnique({ where: { key }, select: { value: true } });
return row?.value ?? '';
}
/** 批量读(避免逐键一条 SQL) */
export async function getSettings(keys: readonly SettingKey[]): Promise<Record<string, string>> {
const rows = await prisma.appSetting.findMany({
where: { key: { in: [...keys] } },
select: { key: true, value: true },
});
const map: Record<string, string> = {};
for (const key of keys) map[key] = '';
for (const row of rows) map[row.key] = row.value;
return map;
}
export async function setSetting(key: SettingKey, value: string): Promise<void> {
await prisma.appSetting.upsert({
where: { key },
create: { key, value },
update: { value },
});
}
export async function getBooleanSetting(key: SettingKey, fallback = false): Promise<boolean> {
const raw = await getSetting(key);
if (raw === '') return fallback;
return raw === 'true' || raw === '1';
}
export async function setBooleanSetting(key: SettingKey, value: boolean): Promise<void> {
await setSetting(key, value ? 'true' : 'false');
if (key === SETTING_KEYS.developerMode) developerModeCache = value;
}
/**
* 开发者模式的内存缓存。
*
* 为什么缓存:错误中间件与日志级别是**热路径**(每个出错请求都要问一次),
* 不可能为此每请求查一次 `AppSetting`。缓存由三处维护:启动时 `refreshDeveloperMode()`、
* 管理员改设置时(`setBooleanSetting` 顺手更新)、以及需要时可手动刷新。
*/
let developerModeCache = false;
export function isDeveloperMode(): boolean {
return developerModeCache;
}
export async function refreshDeveloperMode(): Promise<void> {
developerModeCache = await getBooleanSetting(SETTING_KEYS.developerMode, false);
}
+13
View File
@@ -26,9 +26,18 @@ export const SNAPSHOT_VERSION = 1;
* `StudentClassTransfer` 存调班与转出历史;`ArchivedYear` 是毕业归档的届别档案。
* 历史上那两张 `ClassTeacher`(无科目的协作关系)与 `Enrollment`(与 Student.classId 双写)
* 已随 2026-10-06 的重构删除。
*
* 2026-10-10 新增:权限组(`PermissionGroup` / `PermissionGroupItem` / `UserPermissionGroup`)、
* 全局设置 `AppSetting`(大模型配置与开发者模式开关)、客户端设备与远控
* (`ClientDevice` / `ClientCommand`)、客户端安装包缓存 `ClientRelease`、大模型审计 `AiAudit`。
* **加新表一定同步这里**,否则备份/换库会静默丢表。
*/
export const SNAPSHOT_TABLES = [
'User',
'PermissionGroup',
'PermissionGroupItem',
'UserPermissionGroup',
'AppSetting',
'ArchivedYear',
'Class',
'Student',
@@ -43,6 +52,10 @@ export const SNAPSHOT_TABLES = [
'Grade',
'IntegrationDevice',
'ClassIslandPush',
'ClientDevice',
'ClientCommand',
'ClientRelease',
'AiAudit',
] as const;
export type SnapshotTable = (typeof SNAPSHOT_TABLES)[number];
+79 -22
View File
@@ -1,12 +1,17 @@
import type { Request, RequestHandler } from 'express';
import type { UserRole } from '@classhelper/shared';
import type { PermissionKey, UserRole } from '@classhelper/shared';
import { PERMISSION_LABELS } from '@classhelper/shared';
import { prisma } from '../lib/db.js';
import { ApiError } from '../lib/http.js';
import { loadEffectivePermissions } from '../lib/permissions.js';
import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js';
/**
* 认证中间件:解析 Bearer token 并回查数据库,
* 保证角色 / 班级变更(例如管理员换了班主任)立即生效,不被过期载荷影响。
*
* 2026-10-10 起**顺带装载生效权限集合**(`req.auth.permissions`):
* 角色判据换成权限键判据(`requirePermission`),角色只用来决定"没有分组时的默认模板"。
*/
export function authenticate(): RequestHandler {
return async (req, _res, next) => {
@@ -24,18 +29,16 @@ export function authenticate(): RequestHandler {
});
if (!record) throw ApiError.unauthorized('班级不存在或已被删除');
req.auth = {
token,
user: {
sub: record.id,
username: record.code,
name: record.name,
role: 'CLASS_DEVICE',
classId: record.id,
classSession: true,
classCode: record.code,
},
const user: TokenPayload = {
sub: record.id,
username: record.code,
name: record.name,
role: 'CLASS_DEVICE',
classId: record.id,
classSession: true,
classCode: record.code,
};
req.auth = { token, user, permissions: await loadEffectivePermissions(user) };
next();
return;
}
@@ -47,16 +50,14 @@ export function authenticate(): RequestHandler {
});
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
req.auth = {
token,
user: {
sub: user.id,
username: user.username,
name: user.name,
role: user.role as UserRole,
classId: null,
},
const authUser: TokenPayload = {
sub: user.id,
username: user.username,
name: user.name,
role: user.role as UserRole,
classId: null,
};
req.auth = { token, user: authUser, permissions: await loadEffectivePermissions(authUser) };
next();
} catch (error) {
next(error);
@@ -64,7 +65,7 @@ export function authenticate(): RequestHandler {
};
}
/** 角色守卫 */
/** 角色守卫(仍保留:少数场景要按角色而不是按权限键判断,如班级端专属入口) */
export function requireRole(...roles: UserRole[]): RequestHandler {
return (req, _res, next) => {
const user = req.auth?.user;
@@ -80,8 +81,64 @@ export function requireRole(...roles: UserRole[]): RequestHandler {
};
}
/**
* 权限守卫:要求当前登录主体拥有**全部**列出的权限键。
*
* 权限集合来自 {@link loadEffectivePermissions}(权限组并集,或角色默认模板)。
* 提示文案用中文权限名,用户看到的是"缺少「导入成绩」权限"而不是一堆键名。
*/
export function requirePermission(...keys: PermissionKey[]): RequestHandler {
return (req, _res, next) => {
if (!req.auth?.user) {
next(ApiError.unauthorized());
return;
}
const permissions = req.auth.permissions ?? new Set<PermissionKey>();
const missing = keys.filter((key) => !permissions.has(key));
if (missing.length > 0) {
const labels = missing.map((key) => `「${PERMISSION_LABELS[key]}」`).join('');
next(ApiError.forbidden(`当前账号缺少${labels}权限,请联系管理员在「权限组」里分配`));
return;
}
next();
};
}
/**
* 权限守卫(任一满足):只要拥有其中**一个**权限键就放行。
*
* 用在"同一入口服务多种能力"的接口上:如导入模板按 `kind` 区分能力,
* 只要有一种导入能力就能取模板(具体哪种 kind 能不能导,由 service 里的 kind 断言决定)。
*/
export function requireAnyPermission(...keys: PermissionKey[]): RequestHandler {
return (req, _res, next) => {
if (!req.auth?.user) {
next(ApiError.unauthorized());
return;
}
const permissions = req.auth.permissions ?? new Set<PermissionKey>();
if (keys.length > 0 && !keys.some((key) => permissions.has(key))) {
const labels = keys.map((key) => `「${PERMISSION_LABELS[key]}」`).join('、');
next(ApiError.forbidden(`当前账号缺少权限(${labels} 中至少需要一项),请联系管理员在「权限组」里分配`));
return;
}
next();
};
}
/** 取出当前登录用户,未认证时抛 401 */
export function getAuthUser(req: Request): TokenPayload {
if (!req.auth?.user) throw ApiError.unauthorized();
return req.auth.user;
}
/** 取出当前登录主体的生效权限集合(未认证时抛 401) */
export function getAuthPermissions(req: Request): ReadonlySet<PermissionKey> {
if (!req.auth?.user) throw ApiError.unauthorized();
return req.auth.permissions ?? new Set<PermissionKey>();
}
/** 当前主体是否有某个权限键(页面/服务里做条件分支用,找不到也当无权限) */
export function hasAuthPermission(req: Request, key: PermissionKey): boolean {
return (req.auth?.permissions ?? new Set<PermissionKey>()).has(key);
}
+16 -1
View File
@@ -3,6 +3,7 @@ import { ZodError } from 'zod';
import type { ApiErrorResponse } from '@classhelper/shared';
import { ApiError } from '../lib/http.js';
import { logger } from '../lib/logger.js';
import { isDeveloperMode } from '../lib/settings.js';
import { env } from '../config/env.js';
function sendError(
@@ -36,7 +37,21 @@ export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => {
if (error instanceof ApiError) {
if (error.status >= 500) logger.error(error.message, error.details);
sendError(res, error.status, error.message, error.code, error.details);
/**
* 开发者模式:把 `error.stack` 一并回传,否则"服务端 500"在浏览器里只有一句中文,
* 排查时还得去服务器上翻日志。**500 才带**、且只在管理员显式打开开发者模式时生效
* (开关见 `modules/system`),普通用户看到的东西一个字段都不多。
*/
const withStack =
isDeveloperMode() && error.status >= 500 && error.stack
? {
...(error.details && typeof error.details === 'object'
? (error.details as Record<string, unknown>)
: { details: error.details }),
stack: error.stack,
}
: error.details;
sendError(res, error.status, error.message, error.code, withStack);
return;
}
@@ -0,0 +1,62 @@
import { Router } from 'express';
import { API_PATHS } from '@classhelper/shared';
import { sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
aiAuditQuerySchema,
aiInterpretSchema,
updateAiSettingsSchema,
type AiAuditQuery,
type AiInterpretInput,
type UpdateAiSettingsInput,
} from './ai.schemas.js';
import * as aiService from './ai.service.js';
const router = Router();
router.use(authenticate());
/**
* 大模型助手。
*
* 权限分成两把:`ai:manage`(设置,默认只有管理员)与 `ai:use`(用一句话办事,默认教师也有)。
* **`/interpret` 只解析、绝不落库**:真正发布由前端确认后调既有端点,权限与上课时段保护
* 都在那条路径上(AGENTS.md §5 第 5/14 条)。
*/
router.get('/settings', requirePermission('ai:manage'), async (_req, res) => {
sendOk(res, await aiService.getAiSettings(), '获取大模型设置成功');
});
router.put(
'/settings',
requirePermission('ai:manage'),
validate({ body: updateAiSettingsSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<UpdateAiSettingsInput>(req);
sendOk(res, await aiService.updateAiSettings(user, input), '大模型设置已保存');
},
);
router.post(
'/interpret',
requirePermission('ai:use'),
validate({ body: aiInterpretSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<AiInterpretInput>(req);
sendOk(res, await aiService.interpret(user, input), '助手已解析,请确认后发布');
},
);
router.get('/audit', requirePermission('ai:manage'), validate({ query: aiAuditQuerySchema }), async (req, res) => {
const query = validatedQuery<AiAuditQuery>(req);
sendOk(res, await aiService.listAudit(query.limit ?? 50), '获取助手使用记录成功');
});
export const aiModule = defineModule({
name: 'ai',
basePath: API_PATHS.ai,
router,
});
@@ -0,0 +1,28 @@
import { z } from 'zod';
/** 大模型服务端实现:openai=真接口 / mock=离线确定性结果(自动化验收用,不联网) */
export const AI_PROVIDERS = ['openai', 'mock'] as const;
/** `PUT /api/ai/settings`:字段都可选;`apiKey` 传字符串=换新、null=清除、不传=保持 */
export const updateAiSettingsSchema = z.object({
enabled: z.boolean().optional(),
provider: z.enum(AI_PROVIDERS).optional(),
baseUrl: z.string().trim().max(300).optional(),
model: z.string().trim().max(120).optional(),
apiKey: z.string().trim().max(300).nullable().optional(),
systemPromptExtra: z.string().trim().max(1000).optional(),
});
/** `POST /api/ai/interpret`:一句中文原话 */
export const aiInterpretSchema = z.object({
text: z.string().trim().min(1, '请说一句要做的事').max(500, '一句话最多 500 个字'),
classId: z.string().min(1).optional(),
});
export const aiAuditQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(200).optional(),
});
export type UpdateAiSettingsInput = z.infer<typeof updateAiSettingsSchema>;
export type AiInterpretInput = z.infer<typeof aiInterpretSchema>;
export type AiAuditQuery = z.infer<typeof aiAuditQuerySchema>;
@@ -0,0 +1,367 @@
import { dayKeyLocal, type AiActionKind, type AiAuditDto, type AiInterpretResult, type AiSettingsDto } from '@classhelper/shared';
import { z } from 'zod';
import { getAccessibleClassIds } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { decryptSecret, encryptSecret } from '../../lib/secrets.js';
import { SETTING_KEYS, getSetting, getSettings, setSetting } from '../../lib/settings.js';
import { createHomeworkSchema } from '../homeworks/homeworks.schemas.js';
import { createNotificationSchema } from '../notifications/notifications.schemas.js';
import type { AiInterpretInput, UpdateAiSettingsInput } from './ai.schemas.js';
/**
* 大模型助手:**只解析、不落库**。
*
* 设计边界(照 approved 计划):
* 1. 模型只输出结构化意图(`{action, params, confidence}`),服务端用**与手动入口同一份 zod**
* 二次校验后返回预览;真正落库由前端确认后调既有端点(`POST /homeworks` 等),
* 于是权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用(AGENTS.md §5 第 5/14 条);
* 2. **密钥只进不出**:`AppSetting` 里存的是 `encryptSecret()` 的密文,任何 GET 都不回传;
* 3. `provider: 'mock'` 用确定性规则离线解析 —— 自动化验收**绝不访问外网**(§5 第 52 条),
* 真实模型走 `provider: 'openai'` 时的 `{baseUrl}/chat/completions`。
*/
const DEFAULT_TIMEOUT_MS = 20_000;
/** 与手动入口同源的参数校验(助手不得放宽任何字段约束) */
const homeworkParamsSchema = createHomeworkSchema.pick({
classId: true,
courseId: true,
title: true,
content: true,
assignDate: true,
});
const notificationParamsSchema = createNotificationSchema.pick({
classId: true,
title: true,
content: true,
priority: true,
});
const callParamsSchema = z.object({
classId: z.string().min(1),
studentId: z.string().min(1).optional(),
studentName: z.string().trim().max(40).optional(),
message: z.string().trim().max(200).optional(),
});
const modelReplySchema = z.object({
action: z.enum(['create_homework', 'create_notification', 'call_student', 'unknown']),
params: z.record(z.string(), z.unknown()).default({}),
confidence: z.number().min(0).max(1).optional(),
reason: z.string().max(200).optional(),
});
export async function getAiSettings(): Promise<AiSettingsDto> {
const values = await getSettings([
SETTING_KEYS.aiEnabled,
SETTING_KEYS.aiProvider,
SETTING_KEYS.aiBaseUrl,
SETTING_KEYS.aiModel,
SETTING_KEYS.aiApiKey,
SETTING_KEYS.aiSystemPromptExtra,
]);
const apiKey = decryptSecret(values[SETTING_KEYS.aiApiKey] ?? '');
return {
enabled: values[SETTING_KEYS.aiEnabled] === 'true',
provider: values[SETTING_KEYS.aiProvider] === 'mock' ? 'mock' : 'openai',
baseUrl: values[SETTING_KEYS.aiBaseUrl] ?? '',
model: values[SETTING_KEYS.aiModel] ?? '',
hasApiKey: apiKey.length > 0,
keyHint: apiKey.length >= 4 ? apiKey.slice(-4) : '',
systemPromptExtra: values[SETTING_KEYS.aiSystemPromptExtra] ?? '',
};
}
export async function updateAiSettings(
user: TokenPayload,
input: UpdateAiSettingsInput,
): Promise<AiSettingsDto> {
if (input.enabled !== undefined) await setSetting(SETTING_KEYS.aiEnabled, input.enabled ? 'true' : 'false');
if (input.provider !== undefined) await setSetting(SETTING_KEYS.aiProvider, input.provider);
if (input.baseUrl !== undefined) await setSetting(SETTING_KEYS.aiBaseUrl, input.baseUrl.trim());
if (input.model !== undefined) await setSetting(SETTING_KEYS.aiModel, input.model.trim());
if (input.systemPromptExtra !== undefined) {
await setSetting(SETTING_KEYS.aiSystemPromptExtra, input.systemPromptExtra.trim());
}
if (input.apiKey !== undefined) {
// 传 null = 清除;传字符串 = 换成新密钥(空串按清除处理)
await setSetting(SETTING_KEYS.aiApiKey, input.apiKey ? encryptSecret(input.apiKey.trim()) : '');
}
logger.info(`大模型设置已更新(操作人=${user.username},密钥${input.apiKey ? '已更新' : '未改'})`);
return getAiSettings();
}
interface AssistantContext {
classes: { id: string; name: string }[];
courses: { id: string; name: string }[];
}
async function resolveContext(user: TokenPayload, classId?: string): Promise<AssistantContext> {
const classIds = await getAccessibleClassIds(user);
const classes = await prisma.class.findMany({
where: { id: { in: classIds } },
select: { id: true, name: true },
orderBy: { name: 'asc' },
take: 80,
});
const courses = classId
? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } })
: [];
return { classes, courses };
}
function buildSystemPrompt(context: AssistantContext, extra: string): string {
return [
'你是班级管理系统的指令解析器。把老师的一句中文转成严格 JSON,不要输出任何解释。',
'可选动作(action):create_homework(布置作业)、create_notification(发通知)、call_student(叫学生来)、unknown(看不懂)。',
'输出格式:{"action":"…","params":{…},"confidence":0~1}。',
'create_homework 的 params:{classId, courseId?, title, content, assignDate?(YYYY-MM-DD)}。',
'create_notification 的 params:{classId, title, content, priority?(LOW|NORMAL|HIGH|URGENT)}。',
'call_student 的 params:{classId, studentName, message?}。',
`今天日期:${dayKeyLocal(new Date())}。`,
`可用班级:${context.classes.map((item) => `${item.name}(${item.id})`).join('、') || '无'}。`,
context.courses.length
? `候选班级的科目:${context.courses.map((item) => `${item.name}(${item.id})`).join('、')}。`
: '未指定班级时 courseId 留空。',
'信息不足时把 confidence 压低并在 reason 里说明缺什么;不要编造不存在于上面的班级/科目 id。',
extra ? `学校额外要求:${extra}` : '',
]
.filter(Boolean)
.join('\n');
}
/** 离线确定性解析(provider=mock):只做关键词判断,供自动化与"没配模型时试跑"用 */
function mockInterpret(text: string, context: AssistantContext): z.infer<typeof modelReplySchema> {
// 优先用句子里**真正提到的**班级(「给2026级1班布置作业」);都没提到才退回按名称升序的第一个。
// (曾经只取第一个,结果本机取到了已归档的 2023级1班,与句子里说的班级无关。)
const mentioned = context.classes.find((item) => text.includes(item.name));
const classId = mentioned?.id ?? context.classes[0]?.id ?? '';
if (!classId) return { action: 'unknown', params: {}, confidence: 0.1, reason: '当前账号没有可发布的班级' };
const course = context.courses.find((item) => text.includes(item.name));
if (/作业|布置|卷子|练习|预习|背诵/.test(text)) {
return {
action: 'create_homework',
params: {
classId,
courseId: course?.id ?? null,
title: text.length <= 40 ? text : `${text.slice(0, 38)}…`,
content: text,
assignDate: dayKeyLocal(new Date()),
},
confidence: 0.55,
};
}
if (/通知|告知|提醒|传达/.test(text)) {
return {
action: 'create_notification',
params: {
classId,
title: text.length <= 40 ? text : `${text.slice(0, 38)}…`,
content: text,
priority: /紧急|立即|马上/.test(text) ? 'URGENT' : 'NORMAL',
},
confidence: 0.55,
};
}
if (/叫|找|来一趟|办公室/.test(text)) {
const matched = /\S{2,4}(?=同学|来|到)/.exec(text);
return {
action: 'call_student',
params: { classId, studentName: matched?.[0] ?? '', message: text },
confidence: 0.4,
reason: '离线示例解析只按关键词猜测学生姓名',
};
}
return { action: 'unknown', params: {}, confidence: 0.2, reason: '没听懂要做什么' };
}
async function callOpenAi(
input: AiInterpretInput,
extension: { baseUrl: string; model: string; apiKey: string; prompt: string },
): Promise<z.infer<typeof modelReplySchema>> {
let response: Response;
try {
response = await fetch(`${extension.baseUrl.replace(/\/+$/, '')}/chat/completions`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${extension.apiKey}`,
},
body: JSON.stringify({
model: extension.model || 'gpt-4o-mini',
temperature: 0.2,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: extension.prompt },
{ role: 'user', content: input.text },
],
}),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
} catch (error) {
throw new ApiError(504, 'AI_TIMEOUT', `无法连接大模型服务:${error instanceof Error ? error.message : String(error)}`);
}
if (!response.ok) {
throw ApiError.badRequest(`大模型服务返回 HTTP ${response.status}(请检查服务地址、模型名与密钥)`);
}
const body = (await response.json()) as { choices?: { message?: { content?: string } }[] };
const content = body.choices?.[0]?.message?.content ?? '';
try {
return modelReplySchema.parse(JSON.parse(content));
} catch {
logger.warn(`大模型返回无法解析:${content.slice(0, 200)}`);
throw new ApiError(502, 'AI_INVALID_OUTPUT', '大模型返回的内容不是可用的指令,请换一种说法');
}
}
/** 把校验通过的参数解析成"给人看的预览"(班级/科目/学生名都换成中文) */
async function buildPreview(
action: AiActionKind,
params: Record<string, unknown>,
): Promise<AiInterpretResult['preview']> {
const warnings: string[] = [];
const classId = typeof params.classId === 'string' ? params.classId : '';
const classRecord = classId
? await prisma.class.findUnique({ where: { id: classId }, select: { name: true } })
: null;
if (action === 'create_homework') {
const courseId = typeof params.courseId === 'string' ? params.courseId : '';
const course = courseId
? await prisma.course.findUnique({ where: { id: courseId }, select: { name: true } })
: null;
if (!course) warnings.push('没有指定科目:只有班主任/管理员能发不指定科目的作业');
return {
summary: `给${classRecord?.name ?? '未知班级'}布置作业:${String(params.title ?? '')}`,
className: classRecord?.name ?? null,
courseName: course?.name ?? null,
warnings,
};
}
if (action === 'create_notification') {
return {
summary: `给${classRecord?.name ?? '未知班级'}发通知:${String(params.title ?? '')}`,
className: classRecord?.name ?? null,
warnings,
};
}
if (action === 'call_student') {
const studentName = typeof params.studentName === 'string' ? params.studentName : '';
const student = studentName && classId
? await prisma.student.findFirst({
where: { classId, name: studentName },
select: { id: true, name: true },
})
: null;
if (!student) warnings.push(studentName ? `没找到学生「${studentName}」` : '没识别出学生姓名');
return {
summary: `叫${student?.name ?? (studentName || '某位同学')}到老师这里`,
className: classRecord?.name ?? null,
studentName: student?.name ?? (studentName || null),
warnings,
};
}
return { summary: '没听懂要做什么,请换一种说法', warnings: ['助手没有解析出可用动作'] };
}
/**
* POST /api/ai/interpret - 解析一句中文,返回**待确认**的结构化动作。
* 绝不写业务表;只写一条 `AiAudit`(谁在什么时候让助手做了什么)。
*/
export async function interpret(user: TokenPayload, input: AiInterpretInput): Promise<AiInterpretResult> {
const settings = await getAiSettings();
if (!settings.enabled) {
throw new ApiError(400, 'AI_NOT_CONFIGURED', '助手功能还没开启,请管理员在「大模型设置」里打开');
}
if (settings.provider === 'openai' && (!settings.hasApiKey || !settings.baseUrl)) {
throw new ApiError(400, 'AI_NOT_CONFIGURED', '还没配置大模型服务地址或密钥,请管理员在「大模型设置」里填写');
}
const context = await resolveContext(user, input.classId);
const reply =
settings.provider === 'mock'
? mockInterpret(input.text, context)
: await callOpenAi(input, {
baseUrl: settings.baseUrl,
model: settings.model,
apiKey: decryptSecret(await getSetting(SETTING_KEYS.aiApiKey)),
prompt: buildSystemPrompt(context, settings.systemPromptExtra),
});
// 用与手动入口同一份 zod 再校验一次:模型给的东西**不可信**
let action: AiActionKind = 'unknown';
let params: Record<string, unknown> = {};
const warnings: string[] = [];
try {
if (reply.action === 'create_homework') {
const parsed = homeworkParamsSchema.parse({ ...reply.params, classId: reply.params.classId ?? input.classId });
action = 'create_homework';
params = parsed as unknown as Record<string, unknown>;
} else if (reply.action === 'create_notification') {
const parsed = notificationParamsSchema.parse({
...reply.params,
classId: reply.params.classId ?? input.classId,
});
action = 'create_notification';
params = parsed as unknown as Record<string, unknown>;
} else if (reply.action === 'call_student') {
const parsed = callParamsSchema.parse({ ...reply.params, classId: reply.params.classId ?? input.classId });
action = 'call_student';
params = parsed as unknown as Record<string, unknown>;
}
} catch (error) {
warnings.push('助手给出的参数不完整,请手动补全后再发布');
logger.warn(`助手参数校验失败:${error instanceof Error ? error.message : String(error)}`);
}
const preview = await buildPreview(action, params);
preview.warnings = [...preview.warnings, ...warnings];
if (settings.provider === 'mock') preview.warnings.push('当前是离线示例解析(未配置真实模型),请仔细核对');
await prisma.aiAudit.create({
data: {
userId: user.sub,
prompt: input.text,
intent: action,
status: 'pending',
message: preview.summary,
rawReply: JSON.stringify(reply).slice(0, 2000),
},
});
return {
action,
params,
preview,
confidence: reply.confidence ?? 0.5,
needConfirm: true,
rawReply: null,
};
}
/** GET /api/ai/audit - 助手使用审计(最近 N 条) */
export async function listAudit(limit = 50): Promise<AiAuditDto[]> {
const rows = await prisma.aiAudit.findMany({ orderBy: { createdAt: 'desc' }, take: limit });
// `AiAudit.userId` 是普通字符串列(schema 里没有建 User 关系),所以姓名另外查一次
const userIds = [...new Set(rows.map((row) => row.userId))];
const users = userIds.length
? await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } })
: [];
return rows.map((row) => ({
id: row.id,
userId: row.userId,
userName: users.find((user) => user.id === row.userId)?.name ?? '',
prompt: row.prompt,
intent: row.intent,
status: row.status,
message: row.message,
createdAt: row.createdAt.toISOString(),
}));
}
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -25,13 +25,18 @@ router.use(authenticate());
*/
/** GET /api/archives - 届别列表 */
router.get('/', requireRole('ADMIN'), validate({ query: listArchivesQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await archiveService.listArchivedYears(user), '获取届别列表成功');
});
router.get(
'/',
requirePermission('archive:manage'),
validate({ query: listArchivesQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await archiveService.listArchivedYears(user), '获取届别列表成功');
},
);
/** GET /api/archives/summary - 归档页顶部汇总 + 可归档的届别建议 */
router.get('/summary', requireRole('ADMIN'), async (req, res) => {
router.get('/summary', requirePermission('archive:manage'), async (req, res) => {
sendOk(
res,
{
@@ -43,16 +48,21 @@ router.get('/summary', requireRole('ADMIN'), async (req, res) => {
});
/** GET /api/archives/:id - 某届详情(班级 + 毕业生 + 转出的学生) */
router.get('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await archiveService.getArchivedYearDetail(user, id), '获取届别详情成功');
});
router.get(
'/:id',
requirePermission('archive:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await archiveService.getArchivedYearDetail(user, id), '获取届别详情成功');
},
);
/** GET /api/archives/:id/classes/:classId/content - 归档班级的作业与通知(只读) */
router.get(
'/:id/classes/:classId/content',
requireRole('ADMIN'),
requirePermission('archive:manage'),
validate({ params: archiveClassParamSchema, query: archivedContentQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -67,19 +77,29 @@ router.get(
);
/** POST /api/archives - 执行毕业归档(按届别整届归档) */
router.post('/', requireRole('ADMIN'), validate({ body: createArchiveSchema }), async (req, res) => {
const user = getAuthUser(req);
const result = await archiveService.createArchive(user, validatedBody(req));
sendCreated(res, result, `${result.name} 已归档,共 ${result.classCount} 个班级`);
});
router.post(
'/',
requirePermission('archive:manage'),
validate({ body: createArchiveSchema }),
async (req, res) => {
const user = getAuthUser(req);
const result = await archiveService.createArchive(user, validatedBody(req));
sendCreated(res, result, `${result.name} 已归档,共 ${result.classCount} 个班级`);
},
);
/** DELETE /api/archives/:id - 撤销归档(管理员误操作的退路,不删任何数据) */
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await archiveService.deleteArchive(user, id);
sendOk(res, { id }, '已撤销归档,班级与学生回到在读状态');
});
router.delete(
'/:id',
requirePermission('archive:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await archiveService.deleteArchive(user, id);
sendOk(res, { id }, '已撤销归档,班级与学生回到在读状态');
},
);
export const archivesModule = defineModule({
name: 'archives',
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { sendCreated } from '../../lib/http.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import { createCallSchema, type CreateCallInput } from './calls.schemas.js';
@@ -13,7 +13,7 @@ router.use(authenticate());
* POST /api/calls - 叫人(老师点名让学生过来)
* ClassHelper 班级端会在灵动岛上立即弹出"请 XXX 同学找 XXX 老师",无论是否在上课。
*/
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createCallSchema }), async (req, res) => {
router.post('/', requirePermission('call:create'), validate({ body: createCallSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(res, await callService.createCall(user, req.body as CreateCallInput), '已通知该学生');
});
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import {
validate,
validatedBody,
@@ -56,7 +56,7 @@ router.get('/', validate({ query: listClassesQuerySchema }), async (req, res) =>
*/
router.post(
'/promote',
requireRole('ADMIN'),
requirePermission('class:manage'),
validate({ body: promoteClassesSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -73,7 +73,7 @@ router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
});
/** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */
router.post('/', requireRole('ADMIN'), validate({ body: createClassSchema }), async (req, res) => {
router.post('/', requirePermission('class:manage'), validate({ body: createClassSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(res, await classService.createClass(user, req.body as CreateClassInput), '班级创建成功');
});
@@ -81,7 +81,7 @@ router.post('/', requireRole('ADMIN'), validate({ body: createClassSchema }), as
/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),仅管理员 */
router.patch(
'/:id',
requireRole('ADMIN'),
requirePermission('class:manage'),
validate({ params: idParamSchema, body: updateClassSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -93,7 +93,7 @@ router.patch(
/** DELETE /api/classes/:id - 删除班级 */
router.delete(
'/:id',
requireRole('ADMIN'),
requirePermission('class:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -109,7 +109,7 @@ router.delete(
*/
router.patch(
'/:id/class-account',
requireRole('ADMIN'),
requirePermission('class:manage'),
validate({ params: idParamSchema, body: updateClassAccountSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -128,7 +128,7 @@ router.patch(
*/
router.patch(
'/:id/head-teacher',
requireRole('ADMIN'),
requirePermission('class:manage'),
validate({ params: idParamSchema, body: assignHeadTeacherSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -147,7 +147,7 @@ router.patch(
*/
router.put(
'/:id/subject-teachers',
requireRole('ADMIN'),
requirePermission('class:manage'),
validate({ params: idParamSchema, body: assignSubjectTeachersSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -244,7 +244,7 @@ router.get('/:id/students', validate({ params: idParamSchema }), async (req, res
/** POST /api/classes/:id/students - 把学生加入班级(按学号,已存在则转入并留调班痕迹) */
router.post(
'/:id/students',
requireRole('ADMIN', 'TEACHER'),
requirePermission('roster:manage'),
validate({ params: idParamSchema, body: addStudentSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -256,7 +256,7 @@ router.post(
/** DELETE /api/classes/:id/students/:studentId - 移出学生(回到「未分班」,名单记录保留) */
router.delete(
'/:id/students/:studentId',
requireRole('ADMIN', 'TEACHER'),
requirePermission('roster:manage'),
validate({ params: classStudentParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -0,0 +1,101 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
clientCommandAckSchema,
clientHeartbeatSchema,
createClientCommandSchema,
listClientDevicesQuerySchema,
pushClientUpdateSchema,
type ClientCommandAckInput,
type ClientHeartbeatInput,
type CreateClientCommandInput,
type ListClientDevicesQuery,
type PushClientUpdateInput,
} from './clients.schemas.js';
import * as clientService from './clients.service.js';
const router = Router();
router.use(authenticate());
/**
* POST /api/clients/heartbeat - 客户端每 30 秒上报一次
*
* **不挂权限键**:上报方是教室里的班级端会话(角色 CLASS_DEVICE,权限集为空)。
* 返回值里同时带回"要不要更新 + 安装包信息 + 一条待执行命令",客户端一次请求全拿到。
*/
router.post('/heartbeat', validate({ body: clientHeartbeatSchema }), async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<ClientHeartbeatInput>(req);
sendOk(res, await clientService.heartbeat(user, input, req.ip ?? ''), '心跳已上报');
});
/** POST /api/clients/commands/:id/ack - 执行回执(同样不挂权限键,靠 deviceKey 归属校验) */
router.post(
'/commands/:id/ack',
validate({ params: idParamSchema, body: clientCommandAckSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const input = validatedBody<ClientCommandAckInput>(req);
sendOk(res, await clientService.ackCommand(id, input), '回执已记录');
},
);
/**
* POST /api/clients/commands - 下发远控命令(关机 / 重启 / 注销 / 锁屏)
*
* 权限键 `remote:control`(默认只有管理员有):这是唯一能远程动别人电脑的接口。
*/
router.post(
'/commands',
requirePermission('remote:control'),
validate({ body: createClientCommandSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<CreateClientCommandInput>(req);
sendCreated(res, await clientService.createCommands(user, input), '命令已下发,将在这台电脑下次心跳时送达');
},
);
/** GET /api/clients/devices?classId= - Web 端设备列表(版本 / 在线 / 待执行命令) */
router.get(
'/devices',
requirePermission('client:manage'),
validate({ query: listClientDevicesQuerySchema }),
async (req, res) => {
const query = validatedQuery<ListClientDevicesQuery>(req);
sendOk(res, await clientService.listDevices({ classId: query.classId }), '获取设备列表成功');
},
);
/** POST /api/clients/updates - 统一下发客户端更新 */
router.post(
'/updates',
requirePermission('client:update-push'),
validate({ body: pushClientUpdateSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<PushClientUpdateInput>(req);
sendOk(res, await clientService.pushUpdate(user, input), '更新已下发,将在这台电脑下次心跳时送达');
},
);
/** DELETE /api/clients/devices/:id - 移除设备记录(换机 / 旧机器清理) */
router.delete(
'/devices/:id',
requirePermission('client:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await clientService.deleteDevice(id), '设备已移除');
},
);
export const clientsModule = defineModule({
name: 'clients',
basePath: '/clients',
router,
});
@@ -0,0 +1,62 @@
import { CLIENT_COMMAND_KINDS, CLIENT_COMMAND_RESULT_MAX, type ClientCommandKind } from '@classhelper/shared';
import { z } from 'zod';
/**
* 远控命令种类。用 `CLIENT_COMMAND_KINDS` 而不是再写一遍字面量数组:
* 常量是 `readonly`,`z.enum` 要的是可变元组,所以这里显式转一次类型。
*/
const clientCommandKindSchema = z.enum(
CLIENT_COMMAND_KINDS as unknown as [ClientCommandKind, ...ClientCommandKind[]],
);
/** 设备标识(客户端主进程生成并持久化的 UUID) */
const deviceKeySchema = z.string().trim().min(8, '设备标识不合法').max(80, '设备标识不合法');
/** `POST /api/clients/heartbeat`:客户端每 30 秒一次的上报 */
export const clientHeartbeatSchema = z.object({
deviceKey: deviceKeySchema,
name: z.string().trim().max(60).optional(),
clientVersion: z.string().trim().max(40).optional(),
platform: z.string().trim().max(40).optional(),
pendingVersion: z.string().trim().max(40).nullable().optional(),
stateNote: z.string().trim().max(120).optional(),
});
/**
* `POST /api/clients/commands/:id/ack`:执行回执。
*
* 带上 `deviceKey` 是为了"命令回执必须是那台机器签的字":一个班的班级端令牌
* 全班共用,光凭命令 id 无法区分是班里哪一台。
*/
export const clientCommandAckSchema = z.object({
deviceKey: deviceKeySchema,
ok: z.boolean(),
result: z.string().trim().max(CLIENT_COMMAND_RESULT_MAX).optional(),
});
/** `POST /api/clients/commands`:给选中的电脑下发远控命令 */
export const createClientCommandSchema = z.object({
deviceIds: z.array(z.string().min(1)).min(1, '请选择要操作的电脑').max(200, '一次最多操作 200 台'),
kind: clientCommandKindSchema,
note: z.string().trim().max(80, '备注最多 80 个字').optional(),
});
/** `GET /api/clients/devices?classId=` */
export const listClientDevicesQuerySchema = z.object({
classId: z.string().trim().min(1).optional(),
});
/** `POST /api/clients/updates`:推送更新(按设备 / 按班级 / 全部) */
export const pushClientUpdateSchema = z.object({
deviceIds: z.array(z.string().min(1)).max(500).optional(),
classIds: z.array(z.string().min(1)).max(200).optional(),
all: z.boolean().optional(),
version: z.string().trim().max(40).nullable().optional(),
clear: z.boolean().optional(),
});
export type ClientHeartbeatInput = z.infer<typeof clientHeartbeatSchema>;
export type ClientCommandAckInput = z.infer<typeof clientCommandAckSchema>;
export type CreateClientCommandInput = z.infer<typeof createClientCommandSchema>;
export type ListClientDevicesQuery = z.infer<typeof listClientDevicesQuerySchema>;
export type PushClientUpdateInput = z.infer<typeof pushClientUpdateSchema>;
@@ -0,0 +1,372 @@
import {
CLIENT_COMMAND_EXPIRE_MS,
CLIENT_COMMAND_LABELS,
CLIENT_ONLINE_WINDOW_MS,
type ClientCommandAckRequest,
type ClientCommandDto,
type ClientCommandKind,
type ClientDeviceDto,
type ClientHeartbeatResponse,
type ClientPushResult,
type ClientTargetResult,
} from '@classhelper/shared';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import * as clientReleases from '../update/client-releases.service.js';
import type { ClientHeartbeatInput, CreateClientCommandInput, PushClientUpdateInput } from './clients.schemas.js';
/**
* ClassHelper 桌面客户端设备的心跳 / 远控命令 / 更新下发。
*
* 范式(AGENTS.md §5 第 67 条):**DB 记状态 + 客户端定时拉取** ——
* 与 `IntegrationDevice.requestScheduleReport` 同一套路,不新增长连接握手。
* 因此本文件里没有任何"推送"动作:所有下发都只是改 `ClientDevice` / `ClientCommand` 的字段,
* 由客户端下一次心跳(30 秒内)带走。
*/
type DeviceRow = {
id: string;
classId: string | null;
deviceKey: string;
name: string;
clientVersion: string;
platform: string;
autoUpdate: boolean;
updateTargetVersion: string;
pendingVersion: string;
lastHeartbeatAt: Date | null;
lastIp: string;
stateNote: string;
createdAt: Date;
updatedAt: Date;
class?: { id: string; name: string } | null;
};
type CommandRow = {
id: string;
deviceId: string;
kind: string;
payload: string;
createdAt: Date;
expiresAt: Date | null;
ackedAt: Date | null;
result: string;
};
/** 备注存 JSON 文本(`payload` 字段是预留结构,见 schema 注释) */
function encodePayload(note: string): string {
return JSON.stringify({ note });
}
function decodePayload(raw: string): string {
if (!raw) return '';
try {
const parsed = JSON.parse(raw) as { note?: unknown };
return typeof parsed.note === 'string' ? parsed.note : '';
} catch {
// 兼容手工写进库的纯文本
return raw;
}
}
function toCommandDto(row: CommandRow): ClientCommandDto {
const kind = row.kind as ClientCommandKind;
const label = CLIENT_COMMAND_LABELS[kind] ?? row.kind;
const note = decodePayload(row.payload);
return {
id: row.id,
deviceId: row.deviceId,
kind,
payload: note ? `${label}(${note})` : label,
createdAt: row.createdAt.toISOString(),
expiresAt: row.expiresAt ? row.expiresAt.toISOString() : null,
ackedAt: row.ackedAt ? row.ackedAt.toISOString() : null,
result: row.result,
};
}
/** 待执行的命令:未回执、未撤销、未过期 */
function pendingCommandWhere(deviceIds: string[], now: Date) {
return {
deviceId: { in: deviceIds },
ackedAt: null,
cancelledAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
};
}
function summarize(results: ClientTargetResult[]): ClientPushResult {
const succeeded = results.filter((item) => item.ok).length;
return { total: results.length, succeeded, failed: results.length - succeeded, results };
}
/** 班级端会话(含班级账号)的代表主体就是那个班;教师/管理员个人电脑不绑定班级 */
function resolveClassId(user: TokenPayload): string | null {
return user.classSession || user.role === 'CLASS_DEVICE' ? user.sub : null;
}
async function findPendingCommand(deviceId: string, now: Date): Promise<CommandRow | null> {
const rows = (await prisma.clientCommand.findMany({
where: pendingCommandWhere([deviceId], now),
orderBy: { createdAt: 'asc' },
take: 1,
})) as unknown as CommandRow[];
return rows[0] ?? null;
}
/**
* POST /api/clients/heartbeat - 客户端上报心跳,服务端在同一响应里带回
* 「要不要更新 + 目标版本的安装包信息 + 一条待执行命令」。
*/
export async function heartbeat(
user: TokenPayload,
input: ClientHeartbeatInput,
ip: string,
): Promise<ClientHeartbeatResponse> {
const now = new Date();
const heartbeatAt = {
classId: resolveClassId(user),
name: input.name?.trim() || '未命名设备',
clientVersion: input.clientVersion?.trim() ?? '',
platform: input.platform?.trim() ?? '',
pendingVersion: input.pendingVersion?.trim() ?? '',
stateNote: input.stateNote?.trim() ?? '',
lastHeartbeatAt: now,
lastIp: ip,
};
const device = (await prisma.clientDevice.upsert({
where: { deviceKey: input.deviceKey },
create: { deviceKey: input.deviceKey, ...heartbeatAt },
update: heartbeatAt,
})) as unknown as DeviceRow;
const targetVersion = device.updateTargetVersion.trim();
const [command, update] = await Promise.all([
findPendingCommand(device.id, now),
targetVersion ? clientReleases.resolveUpdateInfo(targetVersion) : Promise.resolve(null),
]);
return {
deviceId: device.id,
serverTime: now.toISOString(),
updateTargetVersion: targetVersion || null,
update,
command: command ? toCommandDto(command) : null,
};
}
/** POST /api/clients/commands/:id/ack - 客户端执行完远控命令后的回执 */
export async function ackCommand(
commandId: string,
input: ClientCommandAckRequest,
): Promise<ClientCommandDto> {
const command = (await prisma.clientCommand.findUnique({
where: { id: commandId },
include: { device: { select: { deviceKey: true, name: true } } },
})) as (CommandRow & { device: { deviceKey: string; name: string } }) | null;
if (!command) throw ApiError.notFound('命令不存在');
if (command.device.deviceKey !== input.deviceKey) {
throw ApiError.forbidden('这条命令不是发给这台电脑的');
}
// 幂等:客户端重试回执不报错,也不覆盖第一次的结果
if (command.ackedAt) return toCommandDto(command);
const detail = input.result?.trim();
const result = `${input.ok ? '已执行' : '执行失败'}${detail ? `:${detail}` : ''}`;
const updated = (await prisma.clientCommand.update({
where: { id: commandId },
data: { ackedAt: new Date(), result },
})) as unknown as CommandRow;
logger.info(`远控命令回执:${command.device.name || command.device.deviceKey} ${result}`);
return toCommandDto(updated);
}
/** GET /api/clients/devices - Web 端「客户端设备」列表(含待执行命令) */
export async function listDevices(options: { classId?: string } = {}): Promise<ClientDeviceDto[]> {
const rows = (await prisma.clientDevice.findMany({
where: options.classId ? { classId: options.classId } : {},
include: { class: { select: { id: true, name: true } } },
orderBy: [{ lastHeartbeatAt: 'desc' }, { createdAt: 'desc' }],
})) as unknown as DeviceRow[];
const now = new Date();
const commands = (await prisma.clientCommand.findMany({
where: pendingCommandWhere(
rows.map((row) => row.id),
now,
),
orderBy: { createdAt: 'asc' },
})) as unknown as CommandRow[];
// 每台设备只留最早的一条(与心跳取的是同一条,界面上看到的才是真会执行的那条)
const pendingByDevice = new Map<string, CommandRow>();
for (const command of commands) {
if (!pendingByDevice.has(command.deviceId)) pendingByDevice.set(command.deviceId, command);
}
return rows.map((row) => {
const heartbeat = row.lastHeartbeatAt;
const pending = pendingByDevice.get(row.id);
return {
id: row.id,
classId: row.classId,
className: row.class?.name ?? null,
deviceKey: row.deviceKey,
name: row.name,
clientVersion: row.clientVersion,
platform: row.platform,
autoUpdate: row.autoUpdate,
updateTargetVersion: row.updateTargetVersion,
pendingVersion: row.pendingVersion,
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
lastIp: row.lastIp,
stateNote: row.stateNote,
online: Boolean(heartbeat && now.getTime() - heartbeat.getTime() < CLIENT_ONLINE_WINDOW_MS),
pendingCommand: pending ? toCommandDto(pending) : null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
});
}
/** 命令去重后的目标设备(保持请求顺序,便于结果一一对应) */
async function findTargetDevices(deviceIds: string[]): Promise<Map<string, DeviceRow>> {
const wanted = [...new Set(deviceIds)];
const rows = (await prisma.clientDevice.findMany({
where: { id: { in: wanted } },
select: { id: true, name: true, deviceKey: true },
})) as unknown as DeviceRow[];
return new Map(rows.map((row) => [row.id, row]));
}
/**
* POST /api/clients/commands - 下发远控命令(关机/重启/注销/锁屏)
*
* 同一台机器上还没执行的旧命令会被**自动撤销**:否则"先关机、后重启"会排队执行,
* 机器刚关就被下一条叫起来,看起来像失控。客户端侧还有一次弹窗二次确认。
*/
export async function createCommands(
user: TokenPayload,
input: CreateClientCommandInput,
): Promise<ClientPushResult> {
const targets = await findTargetDevices(input.deviceIds);
const expiresAt = new Date(Date.now() + CLIENT_COMMAND_EXPIRE_MS);
const note = input.note?.trim() ?? '';
const results: ClientTargetResult[] = [];
for (const deviceId of [...new Set(input.deviceIds)]) {
const device = targets.get(deviceId);
if (!device) {
results.push({
deviceId,
deviceName: '',
ok: false,
code: 'DEVICE_NOT_FOUND',
message: '设备不存在(可能已被移除)',
});
continue;
}
await prisma.clientCommand.updateMany({
where: { deviceId, ackedAt: null, cancelledAt: null },
data: { cancelledAt: new Date(), result: '已被新的命令取代' },
});
await prisma.clientCommand.create({
data: { deviceId, kind: input.kind, payload: encodePayload(note), createdBy: user.sub, expiresAt },
});
results.push({ deviceId, deviceName: device.name, ok: true });
}
const summary = summarize(results);
logger.info(
`下发远控命令 ${input.kind}:目标 ${summary.total} 台,成功 ${summary.succeeded} 台(发起人=${user.username})`,
);
return summary;
}
/** 更新推送的目标集合:优先设备,其次班级,最后"全部" */
async function resolveUpdateTargets(input: PushClientUpdateInput): Promise<DeviceRow[]> {
const deviceIds = [...new Set(input.deviceIds ?? [])];
if (deviceIds.length > 0) {
const rows = (await prisma.clientDevice.findMany({
where: { id: { in: deviceIds } },
select: { id: true, name: true },
})) as unknown as DeviceRow[];
return rows;
}
const classIds = [...new Set(input.classIds ?? [])];
if (classIds.length > 0) {
return (await prisma.clientDevice.findMany({
where: { classId: { in: classIds } },
select: { id: true, name: true },
})) as unknown as DeviceRow[];
}
if (input.all) {
return (await prisma.clientDevice.findMany({ select: { id: true, name: true } })) as unknown as DeviceRow[];
}
throw ApiError.badRequest('请选择要推送的设备、班级,或选择"全部设备"');
}
/**
* POST /api/clients/updates - 统一下发客户端更新
*
* `version` 省略时用服务端缓存里最新的那个版本;`clear: true` 取消下发。
* 只改 `updateTargetVersion`,真正的下载与安装由客户端按自己的空闲判据决定
* (上课时段 / 窗口可见都不装)。
*/
export async function pushUpdate(
user: TokenPayload,
input: PushClientUpdateInput,
): Promise<ClientPushResult> {
const clear = input.clear === true;
let target = '';
if (!clear) {
target = (input.version ?? '').trim() || (await clientReleases.latestReleaseVersion());
if (!target) {
throw ApiError.badRequest('服务端还没有缓存任何客户端安装包,请先下载或登记一份再推送');
}
const info = await clientReleases.resolveUpdateInfo(target);
if (!info) throw ApiError.badRequest(`服务端缺少 v${target} 的安装包文件,请重新下载或登记`);
}
const devices = await resolveUpdateTargets(input);
if (devices.length === 0) throw ApiError.badRequest('没有匹配到任何客户端设备');
const results: ClientTargetResult[] = [];
for (const device of devices) {
await prisma.clientDevice.update({
where: { id: device.id },
data: { updateTargetVersion: target },
});
results.push({ deviceId: device.id, deviceName: device.name, ok: true });
}
const summary = summarize(results);
logger.info(
clear
? `取消更新下发:${summary.total} 台设备(发起人=${user.username})`
: `推送客户端更新 v${target}:${summary.total} 台设备(发起人=${user.username})`,
);
return summary;
}
/** DELETE /api/clients/devices/:id - 移除一台设备记录(换机 / 旧机器清理) */
export async function deleteDevice(deviceId: string): Promise<{ id: string }> {
const device = await prisma.clientDevice.findUnique({
where: { id: deviceId },
select: { id: true, name: true, deviceKey: true },
});
if (!device) throw ApiError.notFound('设备不存在');
await prisma.clientDevice.delete({ where: { id: deviceId } });
logger.info(`移除客户端设备:${device.name || device.deviceKey}`);
return { id: deviceId };
}
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -26,7 +26,7 @@ router.get('/', validate({ query: listCoursesQuerySchema }), async (req, res) =>
/** POST /api/courses - 新增课程 */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
requirePermission('schedule:write'),
validate({ body: createCourseSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -37,7 +37,7 @@ router.post(
/** PATCH /api/courses/:id - 修改课程 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('schedule:write'),
validate({ params: idParamSchema, body: updateCourseSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -49,7 +49,7 @@ router.patch(
/** DELETE /api/courses/:id - 删除课程 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('schedule:write'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,4 +1,4 @@
import type { CourseDto } from '@classhelper/shared';
import type { CourseDto } from '@classhelper/shared';
import { assertCanManageSchedule, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
@@ -21,6 +21,28 @@ export async function listCourses(user: TokenPayload, classId?: string): Promise
export async function createCourse(user: TokenPayload, input: CreateCourseInput): Promise<CourseDto> {
await assertCanManageSchedule(user, input.classId);
/*
* 同名去重(一个班级里科目名唯一)。
*
* 这条不是"友好提示",是**权限护栏**:`course.create` 会把创建者写成这门课的任课老师,
* 于是只要能再建一门同名的「数学」,创建者就凭空成了「数学」的老师 ——
* 后面那道 `assertCanManageSubjectContent` 的科目门随即形同虚设
* (可以布置该科作业、录入该科成绩、删该科内容)。这就是"非该科科任老师
* 能布置其他科作业"的入口。
*
* 处理办法是把**已有**课程的 id 回给前端(409 + details.courseId),让前端复用它;
* 权限判定于是回到"你是不是这门课的任课老师"这个唯一事实上。
*/
const existing = await prisma.course.findFirst({
where: { classId: input.classId, name: input.name },
select: { id: true },
});
if (existing) {
throw new ApiError(409, 'COURSE_EXISTS', `该班级已有名为「${input.name}」的课程`, {
courseId: existing.id,
});
}
const created = await prisma.course.create({
data: { name: input.name, classId: input.classId, teacherId: user.sub },
include: { teacher: teacherSelect },
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { authenticate, requireRole } from '../../middleware/auth.js';
import { authenticate, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -22,7 +22,7 @@ import * as databaseService from './database.service.js';
/**
* 数据库管理(仅管理员):状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。
*
* 改动数据库属于最高危操作,全部接口挂在 requireRole('ADMIN') 之下;
* 改动数据库属于最高危操作,全部接口挂在 requirePermission('database:manage') 之下;
* 前端入口同样只对 ADMIN 显示(Web 端「数据库管理」页)。
*
* 一键切换是异步任务:POST /switch 返回 jobId,前端轮询 GET /switch/jobs/:id 展示步骤进度。
@@ -31,7 +31,7 @@ import * as databaseService from './database.service.js';
const router = Router();
router.use(authenticate());
router.use(requireRole('ADMIN'));
router.use(requirePermission('database:manage'));
/** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */
router.get('/status', async (_req, res) => {
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -56,7 +56,7 @@ router.get(
/** GET /api/grades/stats?classId=&courseId=&examName= - 成绩统计(图表) */
router.get(
'/stats',
requireRole('ADMIN', 'TEACHER'),
requirePermission('grade:read'),
validate({ query: statsQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -68,7 +68,7 @@ router.get(
/** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */
router.get(
'/',
requireRole('ADMIN', 'TEACHER'),
requirePermission('grade:read'),
validate({ query: listGradesQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -83,7 +83,7 @@ router.get(
);
/** POST /api/grades - 录入单条成绩(仅自己任教科目) */
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSchema }), async (req, res) => {
router.post('/', requirePermission('grade:write'), validate({ body: createGradeSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(
res,
@@ -100,7 +100,7 @@ router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSc
*/
router.patch(
'/levels',
requireRole('ADMIN', 'TEACHER'),
requirePermission('grade:write'),
validate({ body: updateGradeLevelsSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -112,7 +112,7 @@ router.patch(
/** POST /api/grades/bulk - 批量录入 / 导入成绩 */
router.post(
'/bulk',
requireRole('ADMIN', 'TEACHER'),
requirePermission('grade:write'),
validate({ body: bulkCreateGradeSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -124,7 +124,7 @@ router.post(
/** PATCH /api/grades/:id - 修改成绩(含单个改等级) */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('grade:write'),
validate({ params: idParamSchema, body: updateGradeSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -136,7 +136,7 @@ router.patch(
/** DELETE /api/grades/:id - 删除成绩 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('grade:write'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,16 +1,18 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
bulkCreateHomeworkSchema,
createHomeworkSchema,
listHomeworkDaysQuerySchema,
listHomeworksQuerySchema,
updateHomeworkSchema,
updateHomeworkStatusSchema,
updateHomeworkSubmissionsSchema,
type BulkCreateHomeworkInput,
type CreateHomeworkInput,
type ListHomeworkDaysInput,
type UpdateHomeworkInput,
@@ -71,10 +73,32 @@ router.post(
},
);
/**
* POST /api/homeworks/bulk - 一次给多个班级布置同一份作业(逐班结果)
*
* 与 POST / 的区别:批量发布是 Web 管理端的"多班同发"工作流,因此这里挂 `homework:write`;
* 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md §5 第 20 条)。
* 每个班的科目归属依然由 createHomework 里的 assertCanManageSubjectContent 逐个判定 ——
* 批量不是绕过科目门的后门(§5 第 5 条)。
*/
router.post(
'/bulk',
requirePermission('homework:write'),
validate({ body: bulkCreateHomeworkSchema }),
async (req, res) => {
const user = getAuthUser(req);
const result = await homeworkService.bulkCreateHomeworks(
user,
req.body as BulkCreateHomeworkInput,
);
sendCreated(res, result, `批量发布完成:成功 ${result.succeeded} 个班,失败 ${result.failed} 个班`);
},
);
/** PATCH /api/homeworks/:id - 修改作业 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('homework:write'),
validate({ params: idParamSchema, body: updateHomeworkSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -90,7 +114,7 @@ router.patch(
/** DELETE /api/homeworks/:id - 删除作业 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('homework:write'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -37,6 +37,26 @@ export const createHomeworkSchema = z.object({
assignDate: dayKeySchema.nullish(),
});
/**
* 一次给多个班级布置同一份作业(批量发布)。
*
* 上限 50 个班是刻意设的:逐班串行落库 + 逐班判定科目归属,
* 一批塞几百个班会长时间占住请求。
*/
export const bulkCreateHomeworkSchema = z.object({
classIds: z
.array(z.string().min(1))
.min(1, '请至少选择一个班级')
.max(50, '一次最多发布 50 个班级'),
courseId: z.string().min(1).nullish(),
/** 按科目名称指定(跨班只能用名称:课程是班级内的,同一门课在每个班是不同的记录) */
subjectName: z.string().trim().min(1).max(50).nullish(),
title: z.string().trim().min(1, '请输入作业标题').max(120),
content: z.string().min(1, '请输入作业内容').max(5000),
assignDate: dayKeySchema.nullish(),
attachmentUrl: attachmentUrlSchema,
});
export const updateHomeworkSchema = z.object({
courseId: z.string().min(1).nullish(),
/** 改作业所属日期(把作业挪到另一天) */
@@ -62,6 +82,7 @@ export const updateHomeworkSubmissionsSchema = z.object({
export type ListHomeworkDaysInput = z.infer<typeof listHomeworkDaysQuerySchema>;
export type CreateHomeworkInput = z.infer<typeof createHomeworkSchema>;
export type BulkCreateHomeworkInput = z.infer<typeof bulkCreateHomeworkSchema>;
export type UpdateHomeworkInput = z.infer<typeof updateHomeworkSchema>;
export type UpdateHomeworkStatusInput = z.infer<typeof updateHomeworkStatusSchema>;
export type UpdateHomeworkSubmissionsInput = z.infer<typeof updateHomeworkSubmissionsSchema>;
@@ -2,6 +2,9 @@ import {
SOCKET_EVENTS,
dayKeyLocal,
shiftDayKey,
type BulkCreateHomeworkRequest,
type BulkPublishClassResult,
type BulkPublishResult,
type HomeworkDaysDto,
type HomeworkDto,
type HomeworkStatusDto,
@@ -15,9 +18,11 @@ import {
isClassDevice,
resolveClassScope,
} from '../../lib/access.js';
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js';
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
import { emitToClass } from '../../realtime/bus.js';
@@ -182,6 +187,63 @@ export async function createHomework(user: TokenPayload, input: CreateHomeworkIn
return dto;
}
/**
* 批量布置作业:一次给多个班级发同一份内容。
*
* 逐班串行调用 `createHomework`(复用同一套科目归属断言 + 实时广播 + 班级端归属规则),
* **某个班失败只记进结果**:例如批量里含一个"语文由别的老师任教"的班,
* 那个班带着「不是你任教的科目」的失败原因回来,其余班照常发布。
*
* 科目用 `subjectName` 而不是 courseId:课程是班级内的,同一门课在每个班是不同的记录,
* 跨班只能用名称逐个查(查不到只让那个班失败,不自动建课)。
*/
export async function bulkCreateHomeworks(
user: TokenPayload,
input: BulkCreateHomeworkRequest,
): Promise<BulkPublishResult> {
const classIds = normalizeBulkClassIds(input.classIds);
const results: BulkPublishClassResult[] = [];
for (const classId of classIds) {
try {
// 批量跨班只能用科目「名称」定位课程:同一门课在每个班是不同的 Course 记录。
// 该班没有这门课时只让这个班失败 —— 不自动建课(否则一次批量会在 N 个班里凭空
// 建出课程,还会让发起人在这些班里变成"任课老师",绕过科目维度的写权限)。
let courseId = input.courseId ?? null;
if (input.subjectName) {
const course = await prisma.course.findFirst({
where: { classId, name: input.subjectName },
select: { id: true },
});
if (!course) {
throw ApiError.badRequest(
`该班级没有「${input.subjectName}」这门课程(跨班发布按科目名称匹配,请先让班主任/管理员在本班建课并分配任课老师,或改选其他科目)`,
);
}
courseId = course.id;
}
const dto = await createHomework(user, {
classId,
courseId,
title: input.title,
content: input.content,
assignDate: input.assignDate ?? null,
attachmentUrl: input.attachmentUrl ?? null,
});
results.push({ classId, ok: true, id: dto.id });
} catch (error) {
results.push(toBulkFailure(classId, error, '布置作业'));
}
}
const summary = summarizeBulk(results);
logger.info(
`批量布置作业:目标 ${summary.total} 个班级,成功 ${summary.succeeded} 个,失败 ${summary.failed} 个(发起人=${user.username})`,
);
return summary;
}
export async function updateHomework(
user: TokenPayload,
homeworkId: string,
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { ApiError, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requireAnyPermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -48,7 +48,7 @@ function resolveImportKind(value: unknown): (typeof IMPORT_KINDS)[number] {
* GET /api/imports/template?kind=grades|students|teachers&format=csv|xlsx - 下载导入模板
* 成绩模板:管理员或班主任(空白模板,不含任何业务数据);学生/教师名单模板:仅管理员
*/
router.get('/template', requireRole('ADMIN', 'TEACHER'), (req, res) => {
router.get('/template', requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'), (req, res) => {
const kind = resolveImportKind(req.query.kind);
assertImportKindAllowed(kind, getAuthUser(req).role);
const format = req.query.format === 'xlsx' ? 'xlsx' : 'csv';
@@ -72,7 +72,7 @@ router.get('/template', requireRole('ADMIN', 'TEACHER'), (req, res) => {
*/
router.post(
'/table/preview',
requireRole('ADMIN', 'TEACHER'),
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'),
validate({ body: tableFileSchema }),
(req, res) => {
const input = validatedBody<TableFileInput>(req);
@@ -89,7 +89,7 @@ router.post(
*/
router.post(
'/table/commit',
requireRole('ADMIN', 'TEACHER'),
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'),
validate({ body: tableCommitSchema }),
async (req, res) => {
const input = validatedBody<TableCommitInput>(req);
@@ -7,7 +7,7 @@ import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { hashPassword } from '../../lib/password.js';
import { resolveUserClassRole } from '../../lib/access.js';
import { canImport } from '@classhelper/shared';
import { canImport, canManageSubjectContent } from '@classhelper/shared';
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
/**
@@ -337,9 +337,19 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
const byName = new Map(students.map((item) => [item.name.toLowerCase(), item]));
const courses = await prisma.course.findMany({
where: { classId },
select: { id: true, name: true },
select: { id: true, name: true, teacherId: true },
});
/*
* 科目维度的第二道门(`assertImportAllowed` 只到"班级"这一层)。
*
* 成绩是**逐行落到具体科目**上的,而班主任并不自动是本班所有科目的老师 ——
* 少了这道检查,班主任就能导入别的老师任教科目的成绩,班里会平白多出一份
* 不属于那门课的记录(且覆盖语义的 append/overwrite 会把它写进同一张表)。
* 判定沿用 shared 的 canManageSubjectContent,与 Web 端单条录入、作业发布**同一份规则**。
*/
const classRole = await resolveUserClassRole(user, classId);
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
@@ -390,6 +400,23 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
if (courseName && !course)
warnings.push(`第 ${rowNumber} 行:科目「${courseName}」不存在,已按"不指定科目"导入`);
// 该科目的成绩能不能由**当前这个人**写(管理员全通;挂了科目就只能是该科任课老师)
const canWriteCourse = canManageSubjectContent({
classRole,
hasSubject: Boolean(course),
isSubjectTeacherOfCourse: course ? course.teacherId === user.sub : false,
allowClassDevice: false,
});
if (!canWriteCourse) {
errors.push({
row: rowNumber,
message: course
? `「${course.name}」不是你任教的科目,不能导入该科目的成绩`
: '不指定科目的成绩只有班主任或管理员可以导入,请填写科目列后再导入',
});
continue;
}
// 表格里带等级列就按"自定义等级"落库(教师明确给了等级),否则按百分制换算
const levelType = levelText ? 'custom' : 'percent';
const existing = await prisma.grade.findFirst({
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import * as gradeService from '../grades/grades.service.js';
@@ -117,7 +117,7 @@ router.use(authenticate());
*/
router.get(
'/devices',
requireRole('ADMIN', 'TEACHER'),
requirePermission('device:manage'),
validate({ query: listDevicesQuerySchema }),
async (req, res) => {
const { classId } = validatedQuery<{ classId?: string }>(req);
@@ -131,14 +131,14 @@ router.get(
* 管理员看全部班级;普通教师看自己担任班主任的班级 ∪ 任课的班级。
* 每条含:班级、设备名/设备号、在线/离线、最后在线时间、最后心跳时间。
*/
router.get('/classisland/status', requireRole('ADMIN', 'TEACHER'), async (req, res) => {
router.get('/classisland/status', requirePermission('device:manage'), async (req, res) => {
sendOk(res, await integrationService.listClassHelperStatus(getAuthUser(req)), '获取在线状态成功');
});
/** POST /api/integrations/devices - 新建设备并一次性返回明文令牌(管理员或本班班主任) */
router.post(
'/devices',
requireRole('ADMIN', 'TEACHER'),
requirePermission('device:manage'),
validate({ body: createDeviceSchema }),
async (req, res) => {
const result = await integrationService.createDevice(
@@ -152,7 +152,7 @@ router.post(
/** PATCH /api/integrations/devices/:id - 改名 / 启停 / 调整同步开关 */
router.patch(
'/devices/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('device:manage'),
validate({ params: deviceIdParamSchema, body: updateDeviceSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
@@ -174,7 +174,7 @@ router.patch(
*/
router.post(
'/devices/:id/request-schedule',
requireRole('ADMIN', 'TEACHER'),
requirePermission('device:manage'),
validate({ params: deviceIdParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
@@ -186,7 +186,7 @@ router.post(
/** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */
router.post(
'/devices/:id/token',
requireRole('ADMIN', 'TEACHER'),
requirePermission('device:manage'),
validate({ params: deviceIdParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
@@ -197,7 +197,7 @@ router.post(
/** DELETE /api/integrations/devices/:id - 删除设备 */
router.delete(
'/devices/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('device:manage'),
validate({ params: deviceIdParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
@@ -209,7 +209,7 @@ router.delete(
/** POST /api/integrations/classisland/notify - 把提醒下发到该班的 ClassIsland 设备 */
router.post(
'/classisland/notify',
requireRole('ADMIN', 'TEACHER'),
requirePermission('classisland:notify'),
validate({ body: sendNotificationSchema }),
async (req, res) => {
const result = await integrationService.sendNotification(
@@ -1,32 +1,46 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
bulkCreateNotificationSchema,
createNotificationSchema,
listNotificationsQuerySchema,
notificationArchiveQuerySchema,
notificationScopeQuerySchema,
type BulkCreateNotificationInput,
type CreateNotificationInput,
type ListNotificationQuery,
type NotificationArchiveQuery,
} from './notifications.schemas.js';
import * as notificationService from './notifications.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/notifications?classId=&priority=&unreadOnly= - 通知列表 */
/** GET /api/notifications?classId=&priority=&unreadOnly=&date=&from=&to=&createdBy= - 通知列表 */
router.get('/', validate({ query: listNotificationsQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{
classId?: string;
priority?: string;
unreadOnly: boolean;
keyword?: string;
}>(req);
const query = validatedQuery<ListNotificationQuery>(req);
sendOk(res, await notificationService.listNotifications(user, query), '获取通知列表成功');
});
/** GET /api/notifications/days?classId=&from=&to=&createdBy= - 按天归档(日期 + 条数) */
router.get('/days', validate({ query: notificationArchiveQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<NotificationArchiveQuery>(req);
sendOk(res, await notificationService.listNotificationDays(user, query), '获取通知日期归档成功');
});
/** GET /api/notifications/creators?classId=&from=&to=&date= - 按发布人归档 */
router.get('/creators', validate({ query: notificationArchiveQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<NotificationArchiveQuery>(req);
sendOk(res, await notificationService.listNotificationCreators(user, query), '获取通知发布人归档成功');
});
/** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */
router.get('/unread-count', validate({ query: notificationScopeQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
@@ -44,7 +58,7 @@ router.post('/read-all', validate({ body: notificationScopeQuerySchema }), async
/** POST /api/notifications - 发布通知(发布后广播 notification:new) */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
requirePermission('notification:publish'),
validate({ body: createNotificationSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -53,6 +67,21 @@ router.post(
},
);
/** POST /api/notifications/bulk - 一次给多个班级发同一条通知(逐班结果,不整批回滚) */
router.post(
'/bulk',
requirePermission('notification:publish'),
validate({ body: bulkCreateNotificationSchema }),
async (req, res) => {
const user = getAuthUser(req);
const result = await notificationService.bulkCreateNotifications(
user,
req.body as BulkCreateNotificationInput,
);
sendCreated(res, result, `批量发布完成:成功 ${result.succeeded} 个班,失败 ${result.failed} 个班`);
},
);
/** POST /api/notifications/:id/read - 标记已读 */
router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
@@ -63,7 +92,7 @@ router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) =
/** DELETE /api/notifications/:id - 删除通知 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('notification:delete'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,13 +1,36 @@
import { z } from 'zod';
import { isDayKey } from '@classhelper/shared';
import { booleanFlagSchema } from '../../lib/schemas.js';
export const notificationPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']);
/** 本地日期(YYYY-MM-DD):按天归档/筛选用它,不要用 createdAt 的 UTC 切片 */
const dayKeySchema = z
.string()
.trim()
.refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' });
export const listNotificationsQuerySchema = z.object({
classId: z.string().min(1).optional(),
priority: notificationPrioritySchema.optional(),
unreadOnly: booleanFlagSchema,
keyword: z.string().trim().max(64).optional(),
/** 只看某一天;与 from/to 二选一 */
date: dayKeySchema.optional(),
/** 日期区间(含边界) */
from: dayKeySchema.optional(),
to: dayKeySchema.optional(),
/** 只看某位发布人 */
createdBy: z.string().min(1).optional(),
});
/** GET /notifications/days 与 GET /notifications/creators 的筛选条件(不含 date 自身) */
export const notificationArchiveQuerySchema = z.object({
classId: z.string().min(1).optional(),
priority: notificationPrioritySchema.optional(),
createdBy: z.string().min(1).optional(),
from: dayKeySchema.optional(),
to: dayKeySchema.optional(),
});
export const createNotificationSchema = z.object({
@@ -26,5 +49,25 @@ export const notificationScopeQuerySchema = z.object({
classId: z.string().min(1).optional(),
});
/**
* 一次给多个班级发同一条通知(批量发布)。
*
* 上限 50 个班是刻意设的:批量发布是逐班串行落库 + 逐班推 ClassIsland,
* 一次塞几百个班会长时间占住请求,也会把教室大屏刷屏。
*/
export const bulkCreateNotificationSchema = z.object({
classIds: z
.array(z.string().min(1))
.min(1, '请至少选择一个班级')
.max(50, '一次最多发布 50 个班级'),
title: z.string().trim().min(1, '请输入通知标题').max(120),
content: z.string().min(1, '请输入通知内容').max(5000),
priority: notificationPrioritySchema.optional(),
confirmDuringClass: z.boolean().optional(),
});
export type CreateNotificationInput = z.infer<typeof createNotificationSchema>;
export type NotificationPriorityInput = z.infer<typeof notificationPrioritySchema>;
export type ListNotificationQuery = z.infer<typeof listNotificationsQuerySchema>;
export type NotificationArchiveQuery = z.infer<typeof notificationArchiveQuerySchema>;
export type BulkCreateNotificationInput = z.infer<typeof bulkCreateNotificationSchema>;
@@ -1,4 +1,15 @@
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import {
SOCKET_EVENTS,
dayKeyLocal,
type BulkCreateNotificationRequest,
type BulkPublishClassResult,
type BulkPublishResult,
type NotificationCreatorSummary,
type NotificationCreatorsDto,
type NotificationDaysDto,
type NotificationDto,
} from '@classhelper/shared';
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
import {
assertClassAccess,
assertCanPublishNotification,
@@ -23,6 +34,37 @@ export interface ListNotificationOptions {
priority?: string;
unreadOnly?: boolean;
keyword?: string;
date?: string;
from?: string;
to?: string;
createdBy?: string;
}
/** 归档筛选条件:按天 / 日期区间 / 发布人 / 优先级(assignDate 是本地日期字符串,字典序即时间序) */
function archiveWhere(options: {
priority?: string;
createdBy?: string;
date?: string;
from?: string;
to?: string;
}): Record<string, unknown> {
if (options.date) {
return {
...(options.priority ? { priority: options.priority } : {}),
...(options.createdBy ? { createdBy: options.createdBy } : {}),
assignDate: options.date,
};
}
const range = {
...(options.from ? { gte: options.from } : {}),
...(options.to ? { lte: options.to } : {}),
};
return {
...(options.priority ? { priority: options.priority } : {}),
...(options.createdBy ? { createdBy: options.createdBy } : {}),
...(Object.keys(range).length > 0 ? { assignDate: range } : {}),
};
}
export async function listNotifications(
@@ -38,7 +80,7 @@ export async function listNotifications(
const notifications = await prisma.notification.findMany({
where: {
...classScopeWhere(scope),
...(options.priority ? { priority: options.priority } : {}),
...archiveWhere(options),
...(options.keyword
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
: {}),
@@ -55,6 +97,98 @@ export async function listNotifications(
return notifications.map((item) => toNotificationDto(item, { studentIds: personalIds, withStatus: isStaff }));
}
/**
* 按天归档:列出「有通知的日期 + 当天条数」,供 Web 端左侧日期栏使用。
*
* 用 `assignDate`(本地日期)而不是 `createdAt`:晚上发的通知不该被算到第二天。
* 传了 `date` 也刻意忽略 —— 日期栏是"归档索引",不该被当前选中的那天过滤掉。
*/
export async function listNotificationDays(
user: TokenPayload,
options: { classId?: string; priority?: string; createdBy?: string; from?: string; to?: string },
): Promise<NotificationDaysDto> {
const scope = await resolveClassScope(user, options.classId);
const grouped = await prisma.notification.groupBy({
by: ['assignDate'],
where: { ...classScopeWhere(scope), ...archiveWhere(options) },
_count: { _all: true },
});
const days = grouped
.map((item) => ({ date: item.assignDate, count: item._count._all }))
.filter((item) => item.date !== '')
.sort((left, right) => right.date.localeCompare(left.date));
return { days };
}
/** 按发布人归档:列出当前筛选范围内的发布人及其条数(供"按发布人看通知"用) */
export async function listNotificationCreators(
user: TokenPayload,
options: { classId?: string; priority?: string; date?: string; from?: string; to?: string },
): Promise<NotificationCreatorsDto> {
const scope = await resolveClassScope(user, options.classId);
const grouped = await prisma.notification.groupBy({
by: ['createdBy'],
where: { ...classScopeWhere(scope), ...archiveWhere(options) },
_count: { _all: true },
});
const owners = await prisma.user.findMany({
where: { id: { in: grouped.map((item) => item.createdBy) } },
select: { id: true, name: true, username: true },
});
const creators: NotificationCreatorSummary[] = grouped
.map((item) => {
const owner = owners.find((candidate) => candidate.id === item.createdBy);
return {
id: item.createdBy,
name: owner?.name ?? '未知用户',
username: owner?.username ?? '',
count: item._count._all,
};
})
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name, 'zh-CN'));
return { creators };
}
/**
* 批量发布通知:一次给多个班级发同一条内容。
*
* 逐班串行调用 `createNotification`(复用同一套权限断言 + 上课保护 + 实时广播 + ClassIsland 推送),
* 某个班失败只记进结果,不影响其他班 —— 前端拿到逐班结果后自己决定怎么展示与重试。
*/
export async function bulkCreateNotifications(
user: TokenPayload,
input: BulkCreateNotificationRequest,
): Promise<BulkPublishResult> {
const classIds = normalizeBulkClassIds(input.classIds);
const results: BulkPublishClassResult[] = [];
for (const classId of classIds) {
try {
const dto = await createNotification(user, {
classId,
title: input.title,
content: input.content,
priority: input.priority,
confirmDuringClass: input.confirmDuringClass,
});
results.push({ classId, ok: true, id: dto.id });
} catch (error) {
results.push(toBulkFailure(classId, error, '发布通知'));
}
}
const summary = summarizeBulk(results);
logger.info(
`批量发布通知:目标 ${summary.total} 个班级,成功 ${summary.succeeded} 个,失败 ${summary.failed} 个(发起人=${user.username})`,
);
return summary;
}
/**
* 发布通知并实时广播到 class:{classId} 房间。
*
@@ -92,6 +226,8 @@ export async function createNotification(
content: input.content,
priority,
createdBy: user.sub,
// 按天归档用「本地日期」:晚间发布的通知不会被 UTC 切片算到第二天(与作业同口径)
assignDate: dayKeyLocal(new Date()),
},
include: { creator: creatorSelect, reads: true },
});
@@ -0,0 +1,97 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
assignUserGroupsSchema,
createPermissionGroupSchema,
updatePermissionGroupSchema,
type AssignUserGroupsInput,
type CreatePermissionGroupInput,
type UpdatePermissionGroupInput,
} from './permissions.schemas.js';
import * as permissionService from './permissions.service.js';
const router = Router();
router.use(authenticate());
/**
* GET /api/permissions/me - 当前账号的生效权限。
*
* **故意不挂权限键**:任何登录账号都要能读自己的权限(两端据此决定入口显不显示),
* 而"能读自己有什么权限"本身不是什么机密。班级端会话返回空集。
*/
router.get('/me', async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await permissionService.myPermissions(user), '获取当前权限成功');
});
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树的唯一来源) */
router.get('/catalog', requirePermission('permission:manage'), async (_req, res) => {
sendOk(res, permissionService.permissionCatalog(), '获取权限目录成功');
});
/** GET /api/permissions/groups - 权限组列表 */
router.get('/groups', requirePermission('permission:manage'), async (_req, res) => {
sendOk(res, await permissionService.listGroups(), '获取权限组成功');
});
/** POST /api/permissions/groups - 新建权限组 */
router.post(
'/groups',
requirePermission('permission:manage'),
validate({ body: createPermissionGroupSchema }),
async (req, res) => {
const input = validatedBody<CreatePermissionGroupInput>(req);
sendCreated(res, await permissionService.createGroup(input), '权限组已创建');
},
);
/** PATCH /api/permissions/groups/:id - 改名 / 改描述 / 整体替换权限键 */
router.patch(
'/groups/:id',
requirePermission('permission:manage'),
validate({ params: idParamSchema, body: updatePermissionGroupSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const input = validatedBody<UpdatePermissionGroupInput>(req);
sendOk(res, await permissionService.updateGroup(id, input), '权限组已更新');
},
);
/** DELETE /api/permissions/groups/:id - 删除权限组(内置组不允许删除) */
router.delete(
'/groups/:id',
requirePermission('permission:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await permissionService.deleteGroup(id);
sendOk(res, { id }, '权限组已删除,原成员回到角色默认权限');
},
);
/** GET /api/permissions/users - 成员列表(含所属权限组与生效权限数量) */
router.get('/users', requirePermission('permission:manage'), async (_req, res) => {
sendOk(res, await permissionService.listMembers(), '获取成员成功');
});
/** PUT /api/permissions/users/:id/groups - 整体替换某账号的权限组 */
router.put(
'/users/:id/groups',
requirePermission('permission:manage'),
validate({ params: idParamSchema, body: assignUserGroupsSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const input = validatedBody<AssignUserGroupsInput>(req);
sendOk(res, await permissionService.assignUserGroups(id, input), '权限组分配已保存');
},
);
export const permissionsModule = defineModule({
name: 'permissions',
basePath: '/permissions',
router,
});
@@ -0,0 +1,47 @@
import { PERMISSION_KEYS, type PermissionKey } from '@classhelper/shared';
import { z } from 'zod';
/**
* 权限键校验:必须是 shared 的 `PERMISSION_KEYS` 之一。
*
* 用 refine 而不是 `z.enum(PERMISSION_KEYS)`:后者需要一个非空元组类型,
* 而 `PERMISSION_KEYS` 是 `as const` 推导出来的只读元组,转换写法又长又脆。
*
* 写入口一律拒绝未知键;**读出口静默忽略**(库里可能留着已下线的键)——
* 见 `lib/permissions.ts` 的 known 集合。
*/
export const permissionKeySchema = z
.string()
.refine((value): value is PermissionKey => (PERMISSION_KEYS as readonly string[]).includes(value), {
message: '未知的权限键',
});
/** 一次最多 200 个键(目录现在不到 40 个,留余量同时挡住离谱的请求体) */
const keysSchema = z.array(permissionKeySchema).max(200, '一次最多提交 200 个权限键');
export const createPermissionGroupSchema = z.object({
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字'),
description: z.string().trim().max(200, '描述最多 200 个字').optional(),
keys: keysSchema.optional(),
});
/**
* 改权限组:字段都是可选的,**传了就整体替换**。
*
* `keys` 是"整体替换"而不是增删(前端权限页本来就是一次提交整棵勾选树),
* 这样不会出现"看着没勾上、其实还生效"的幽灵权限。
*/
export const updatePermissionGroupSchema = z.object({
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字').optional(),
description: z.string().trim().max(200, '描述最多 200 个字').optional(),
keys: keysSchema.optional(),
});
/** 给账号分配权限组:整体替换;传空数组 = 回到角色默认模板 */
export const assignUserGroupsSchema = z.object({
groupIds: z.array(z.string().min(1)).max(50, '一次最多分配 50 个权限组'),
});
export type CreatePermissionGroupInput = z.infer<typeof createPermissionGroupSchema>;
export type UpdatePermissionGroupInput = z.infer<typeof updatePermissionGroupSchema>;
export type AssignUserGroupsInput = z.infer<typeof assignUserGroupsSchema>;
@@ -0,0 +1,326 @@
import {
PERMISSION_CATEGORIES,
PERMISSION_DEFAULT_ROLES,
PERMISSION_KEYS,
PERMISSION_LABELS,
ROLE_DEFAULT_PERMISSIONS,
type AssignUserGroupsRequest,
type CreatePermissionGroupRequest,
type MyPermissionsDto,
type PermissionCatalogDto,
type PermissionGroupDto,
type PermissionKey,
type PermissionMemberDto,
type UpdatePermissionGroupRequest,
type UserRole,
} from '@classhelper/shared';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { ADMIN_UNREMOVABLE_PERMISSIONS, loadEffectivePermissions } from '../../lib/permissions.js';
/**
* 权限组服务:组 / 键 / 成员三件事的全部读写都收在这里。
*
* 生效权限的计算**不在本文件**,而在 `lib/permissions.ts` 的 `loadEffectivePermissions`
* (鉴权中间件每请求调用一次);本文件只负责"编辑这些组",两者共用同一份 shared 常量。
*/
const GROUP_SELECT = {
id: true,
name: true,
description: true,
builtin: true,
createdAt: true,
updatedAt: true,
items: { select: { key: true } },
_count: { select: { users: true } },
} as const;
type GroupRow = {
id: string;
name: string;
description: string;
builtin: boolean;
createdAt: Date;
updatedAt: Date;
items: { key: string }[];
_count: { users: number };
};
const MEMBER_SELECT = {
id: true,
username: true,
name: true,
role: true,
permissionGroups: { select: { groupId: true } },
} as const;
type MemberRow = {
id: string;
username: string;
name: string;
role: string;
permissionGroups: { groupId: string }[];
};
/** 目录顺序(key → 序号):组的键与"我的权限"都按它排序,前端渲染才稳定 */
const KEY_ORDER = new Map<string, number>(PERMISSION_KEYS.map((key, index) => [key, index]));
function knownKey(key: string): key is PermissionKey {
return KEY_ORDER.has(key);
}
function byCatalogOrder(a: string, b: string): number {
return (KEY_ORDER.get(a) ?? Number.MAX_SAFE_INTEGER) - (KEY_ORDER.get(b) ?? Number.MAX_SAFE_INTEGER);
}
/** 去重 + 丢掉已下线的键 + 按目录顺序排序 */
function normalizeKeys(keys: readonly string[] | undefined): PermissionKey[] {
const unique = new Set<PermissionKey>();
for (const key of keys ?? []) if (knownKey(key)) unique.add(key);
return [...unique].sort(byCatalogOrder);
}
function toGroupDto(row: GroupRow): PermissionGroupDto {
return {
id: row.id,
name: row.name,
description: row.description,
builtin: row.builtin,
keys: row.items.map((item) => item.key).filter(knownKey).sort(byCatalogOrder),
memberCount: row._count.users,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树都按它渲染) */
export function permissionCatalog(): PermissionCatalogDto {
const roles = Object.keys(ROLE_DEFAULT_PERMISSIONS) as UserRole[];
return {
keys: [...PERMISSION_KEYS],
labels: { ...PERMISSION_LABELS },
categories: PERMISSION_CATEGORIES.map((category) => ({
title: category.title,
keys: [...category.keys],
})),
defaultRoles: Object.fromEntries(
PERMISSION_KEYS.map((key) => [key, [...PERMISSION_DEFAULT_ROLES[key]]]),
) as Record<PermissionKey, UserRole[]>,
roleDefaults: Object.fromEntries(
roles.map((role) => [role, [...ROLE_DEFAULT_PERMISSIONS[role]]]),
) as Record<UserRole, PermissionKey[]>,
unremovable: [...ADMIN_UNREMOVABLE_PERMISSIONS],
};
}
/** GET /api/permissions/groups - 权限组列表(内置组排在最前,其余按创建时间) */
export async function listGroups(): Promise<PermissionGroupDto[]> {
const rows = await prisma.permissionGroup.findMany({
select: GROUP_SELECT,
orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }],
});
return (rows as GroupRow[]).map(toGroupDto);
}
export async function createGroup(input: CreatePermissionGroupRequest): Promise<PermissionGroupDto> {
const name = input.name.trim();
const sameName = await prisma.permissionGroup.findUnique({ where: { name }, select: { id: true } });
if (sameName) throw ApiError.conflict(`已存在名为「${name}」的权限组`);
const keys = normalizeKeys(input.keys);
const created = await prisma.permissionGroup.create({
data: {
name,
description: input.description?.trim() ?? '',
items: { create: keys.map((key) => ({ key })) },
},
select: GROUP_SELECT,
});
logger.info(`新建权限组「${name}」(${keys.length} 个权限键)`);
return toGroupDto(created as GroupRow);
}
export async function updateGroup(
groupId: string,
input: UpdatePermissionGroupRequest,
): Promise<PermissionGroupDto> {
const current = await prisma.permissionGroup.findUnique({
where: { id: groupId },
select: { id: true, name: true },
});
if (!current) throw ApiError.notFound('权限组不存在');
const name = input.name?.trim();
if (name && name !== current.name) {
const sameName = await prisma.permissionGroup.findUnique({ where: { name }, select: { id: true } });
if (sameName) throw ApiError.conflict(`已存在名为「${name}」的权限组`);
}
const keys = input.keys ? normalizeKeys(input.keys) : null;
await prisma.$transaction(async (tx) => {
await tx.permissionGroup.update({
where: { id: groupId },
data: {
...(name ? { name } : {}),
...(input.description !== undefined ? { description: input.description.trim() } : {}),
},
});
// 键是整体替换:先清后建。漏传某个键就等于"取消勾选",不会留下幽灵权限。
if (keys) {
await tx.permissionGroupItem.deleteMany({ where: { groupId } });
if (keys.length > 0) {
await tx.permissionGroupItem.createMany({ data: keys.map((key) => ({ groupId, key })) });
}
}
});
const updated = await prisma.permissionGroup.findUniqueOrThrow({
where: { id: groupId },
select: GROUP_SELECT,
});
logger.info(
`更新权限组「${(updated as GroupRow).name}」(${keys ? `权限键 ${keys.length} 个` : '未改权限键'})`,
);
return toGroupDto(updated as GroupRow);
}
export async function deleteGroup(groupId: string): Promise<{ id: string }> {
const current = await prisma.permissionGroup.findUnique({
where: { id: groupId },
select: { id: true, name: true, builtin: true, _count: { select: { users: true } } },
});
if (!current) throw ApiError.notFound('权限组不存在');
if (current.builtin) {
throw ApiError.badRequest(`「${current.name}」是内置权限组,不能删除(可以改名或调整里面的权限)`);
}
// 键与成员关系都是 onDelete: Cascade,直接删组即可;
// 组内成员随后"没有任何分组",会回落角色默认模板(不会变成空权限)。
await prisma.permissionGroup.delete({ where: { id: groupId } });
logger.info(
`删除权限组「${current.name}」(原成员 ${current._count.users} 个,已回到角色默认权限)`,
);
return { id: groupId };
}
/** 把一行 User(含分组关联)转成权限页用的成员 DTO */
function toMemberDto(
row: MemberRow,
keysOfGroup: Map<string, PermissionKey[]>,
): PermissionMemberDto {
const groupIds = row.permissionGroups.map((link) => link.groupId);
const union = new Set<PermissionKey>();
for (const groupId of groupIds) for (const key of keysOfGroup.get(groupId) ?? []) union.add(key);
const role = row.role as UserRole;
const permissionCount =
role === 'ADMIN'
? PERMISSION_KEYS.length
: groupIds.length === 0
? ROLE_DEFAULT_PERMISSIONS[role].length
: union.size;
return {
id: row.id,
username: row.username,
name: row.name,
role,
groupIds,
permissionCount,
fixed: role === 'ADMIN',
};
}
/** GET /api/permissions/users - 成员列表(管理员/教师账号;班级端账号不参与权限组) */
export async function listMembers(): Promise<PermissionMemberDto[]> {
const [rows, groups] = await Promise.all([
prisma.user.findMany({
where: { role: { in: ['ADMIN', 'TEACHER'] } },
select: MEMBER_SELECT,
orderBy: [{ role: 'asc' }, { name: 'asc' }],
}),
prisma.permissionGroup.findMany({ select: { id: true, items: { select: { key: true } } } }),
]);
const keysOfGroup = new Map<string, PermissionKey[]>(
groups.map((group) => [
group.id,
group.items.map((item) => item.key).filter(knownKey).sort(byCatalogOrder),
]),
);
return (rows as MemberRow[]).map((row) => toMemberDto(row, keysOfGroup));
}
/** PUT /api/permissions/users/:id/groups - 整体替换某账号的权限组 */
export async function assignUserGroups(
userId: string,
input: AssignUserGroupsRequest,
): Promise<PermissionMemberDto> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: MEMBER_SELECT });
if (!user) throw ApiError.notFound('账号不存在');
const row = user as MemberRow;
if (row.role === 'ADMIN') {
throw ApiError.badRequest('管理员固定拥有全部权限,不需要(也不能)分配权限组');
}
if (row.role === 'CLASS_DEVICE') {
throw ApiError.badRequest(
'ClassHelper 班级端账号的能力由"班级内角色"决定(见权限矩阵),不参与权限组分配',
);
}
const groupIds = [...new Set(input.groupIds)];
if (groupIds.length > 0) {
const found = await prisma.permissionGroup.count({ where: { id: { in: groupIds } } });
if (found !== groupIds.length) throw ApiError.badRequest('有权限组不存在,请刷新后重试');
}
await prisma.$transaction(async (tx) => {
await tx.userPermissionGroup.deleteMany({ where: { userId } });
if (groupIds.length > 0) {
await tx.userPermissionGroup.createMany({
data: groupIds.map((groupId) => ({ userId, groupId })),
});
}
});
logger.info(
`分配权限组:${row.username} → ${groupIds.length > 0 ? `${groupIds.length} 个组` : '角色默认模板'}`,
);
const groups = await prisma.permissionGroup.findMany({
select: { id: true, items: { select: { key: true } } },
});
const keysOfGroup = new Map<string, PermissionKey[]>(
groups.map((group) => [
group.id,
group.items.map((item) => item.key).filter(knownKey).sort(byCatalogOrder),
]),
);
return toMemberDto({ ...row, permissionGroups: groupIds.map((groupId) => ({ groupId })) }, keysOfGroup);
}
/**
* GET /api/permissions/me - 当前账号的生效权限。
*
* 两端按它决定"入口显不显示"(真正的拦截始终在服务端)。
* 班级端会话恒为空集:它的能力由班级维度断言决定。
*/
export async function myPermissions(user: TokenPayload): Promise<MyPermissionsDto> {
const permissions = await loadEffectivePermissions(user);
const fromGroups =
user.classSession || user.role === 'CLASS_DEVICE' || user.role === 'ADMIN'
? false
: (await prisma.userPermissionGroup.count({ where: { userId: user.sub } })) > 0;
return {
role: user.role,
permissions: [...permissions].sort(byCatalogOrder),
fromGroups,
};
}
+8
View File
@@ -1,7 +1,9 @@
import { aiModule } from './ai/ai.module.js';
import { archivesModule } from './archives/archives.module.js';
import { authModule } from './auth/auth.module.js';
import { callsModule } from './calls/calls.module.js';
import { classesModule } from './classes/classes.module.js';
import { clientsModule } from './clients/clients.module.js';
import { coursesModule } from './courses/courses.module.js';
import { databaseModule } from './database/database.module.js';
import { dashboardModule } from './dashboard/dashboard.module.js';
@@ -10,8 +12,10 @@ import { homeworksModule } from './homeworks/homeworks.module.js';
import { importsModule } from './imports/imports.module.js';
import { integrationsModule } from './integrations/integrations.module.js';
import { notificationsModule } from './notifications/notifications.module.js';
import { permissionsModule } from './permissions/permissions.module.js';
import { schedulesModule } from './schedules/schedules.module.js';
import { studentsModule } from './students/students.module.js';
import { systemModule } from './system/system.module.js';
import { teachersModule } from './teachers/teachers.module.js';
import { termModule } from './term/term.module.js';
import { updateModule } from './update/update.module.js';
@@ -26,16 +30,20 @@ import type { ApiModule } from './module.types.js';
*/
export const apiModules: ApiModule[] = [
authModule,
aiModule,
classesModule,
clientsModule,
coursesModule,
schedulesModule,
homeworksModule,
notificationsModule,
permissionsModule,
callsModule,
importsModule,
integrationsModule,
gradesModule,
studentsModule,
systemModule,
teachersModule,
archivesModule,
termModule,
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -58,7 +58,7 @@ router.get('/current', validate({ query: classStatusQuerySchema }), async (req,
/** POST /api/schedules - 新增课表 */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
requirePermission('schedule:write'),
validate({ body: createScheduleSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -73,7 +73,7 @@ router.post(
/** PATCH /api/schedules/:id - 修改课表 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('schedule:write'),
validate({ params: idParamSchema, body: updateScheduleSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -89,7 +89,7 @@ router.patch(
/** DELETE /api/schedules/:id - 删除课表 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('schedule:write'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -33,7 +33,7 @@ router.use(authenticate());
*/
router.get(
'/',
requireRole('ADMIN', 'TEACHER'),
requirePermission('roster:manage'),
validate({ query: listStudentsQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -45,7 +45,7 @@ router.get(
/** GET /api/students/transfers - 调班 / 转出历史(管理员看全部,也可按学生或班级过滤) */
router.get(
'/transfers',
requireRole('ADMIN', 'TEACHER'),
requirePermission('roster:manage'),
validate({ query: listTransfersQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -62,7 +62,7 @@ router.get(
*/
router.post(
'/transfer',
requireRole('ADMIN'),
requirePermission('class:manage'),
validate({ body: transferStudentsSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -74,7 +74,7 @@ router.post(
/** POST /api/students/transfer-out - 学生转出(学籍离开本校,不是调班) */
router.post(
'/transfer-out',
requireRole('ADMIN'),
requirePermission('class:manage'),
validate({ body: transferOutStudentsSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -86,7 +86,7 @@ router.post(
/** GET /api/students/:id/transfers - 单个学生的调班 / 转出历史 */
router.get(
'/:id/transfers',
requireRole('ADMIN', 'TEACHER'),
requirePermission('roster:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -99,7 +99,7 @@ router.get(
/** POST /api/students - 新建学生(名单记录,不是账号) */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
requirePermission('roster:manage'),
validate({ body: createStudentSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -114,7 +114,7 @@ router.post(
/** PATCH /api/students/:id - 修改学生信息 / 调班 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('roster:manage'),
validate({ params: idParamSchema, body: updateStudentSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -130,7 +130,7 @@ router.patch(
/** DELETE /api/students/:id - 删除学生名单记录 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('roster:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -0,0 +1,62 @@
import { Router } from 'express';
import { API_PATHS } from '@classhelper/shared';
import { sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
updateAutostartSchema,
updateSystemSettingsSchema,
type UpdateAutostartInput,
type UpdateSystemSettingsInput,
} from './system.schemas.js';
import * as systemService from './system.service.js';
const router = Router();
router.use(authenticate());
/**
* 系统设置与运维接口(**全部仅 ADMIN**,权限键 `system:manage`)。
*
* 这些接口改的是"这台服务器怎么运行"(开发者模式、开机自启),
* 与业务数据无关,因此不走任何班级维度断言。
*/
router.get('/settings', requirePermission('system:manage'), async (_req, res) => {
sendOk(res, await systemService.getSystemSettings(), '获取系统设置成功');
});
router.put(
'/settings',
requirePermission('system:manage'),
validate({ body: updateSystemSettingsSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<UpdateSystemSettingsInput>(req);
sendOk(res, await systemService.updateSystemSettings(user, input), '系统设置已保存');
},
);
router.get('/autostart', requirePermission('system:manage'), async (_req, res) => {
sendOk(res, await systemService.getAutostartStatus(), '获取开机自启状态成功');
});
router.put(
'/autostart',
requirePermission('system:manage'),
validate({ body: updateAutostartSchema }),
async (req, res) => {
const input = validatedBody<UpdateAutostartInput>(req);
sendOk(res, await systemService.updateAutostart(input), '开机自启设置已生效');
},
);
/** 运行诊断:开发者模式下排查"服务端到底跑的哪份代码、数据在哪" */
router.get('/diagnostics', requirePermission('system:manage'), async (_req, res) => {
sendOk(res, await systemService.getDiagnostics(), '获取运行诊断成功');
});
export const systemModule = defineModule({
name: 'system',
basePath: API_PATHS.system,
router,
});
@@ -0,0 +1,14 @@
import { z } from 'zod';
/** `PUT /api/system/settings`:目前只有开发者模式(字段都可选,传了就改) */
export const updateSystemSettingsSchema = z.object({
developerMode: z.boolean().optional(),
});
/** `PUT /api/system/autostart`:开/关服务端开机自启 */
export const updateAutostartSchema = z.object({
enabled: z.boolean(),
});
export type UpdateSystemSettingsInput = z.infer<typeof updateSystemSettingsSchema>;
export type UpdateAutostartInput = z.infer<typeof updateAutostartSchema>;
@@ -0,0 +1,153 @@
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import {
CLIENT_ONLINE_WINDOW_MS,
type AutostartStatusDto,
type SystemDiagnosticsDto,
type SystemSettingsDto,
} from '@classhelper/shared';
import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { SETTING_KEYS, getBooleanSetting, setBooleanSetting } from '../../lib/settings.js';
import { APP_VERSION } from '../../lib/version.js';
import type { UpdateAutostartInput, UpdateSystemSettingsInput } from './system.schemas.js';
/**
* 服务端系统设置:开发者模式、开机自启、运行诊断。
*
* 为什么"开机自启"要做成接口而不是只在安装器里勾一下:教室服务器换机器、改过安装目录、
* 或者管理员当时没勾,事后都得能补 —— 安装器只负责**默认值**,运行期由这里改。
* 它改的是**操作系统**的东西(注册表 Run 项 / systemd 单元),所以:
* 1. 只在"装好的形态"里放行(开发机、免安装包、容器里返回 `supported:false`);
* 2. 失败原因是运维性质的(权限不足、单元文件不存在),一律带原文回给管理员,不吞成 500。
*/
const RUN_KEY_PATH = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
const RUN_KEY_NAME = 'ClassHelperServer';
const SYSTEMD_UNIT = 'classhelper';
function runCommand(file: string, args: string[]): { ok: boolean; output: string } {
const result = spawnSync(file, args, { encoding: 'utf8', windowsHide: true });
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
return { ok: result.status === 0, output: output || (result.error ? result.error.message : '') };
}
/** 安装器写在安装目录里的启动脚本:它存在才算"装好的形态" */
function startCmdPath(): string {
return path.join(path.dirname(process.execPath), 'start.cmd');
}
export async function getSystemSettings(): Promise<SystemSettingsDto> {
return { developerMode: await getBooleanSetting(SETTING_KEYS.developerMode, false) };
}
export async function updateSystemSettings(
user: TokenPayload,
input: UpdateSystemSettingsInput,
): Promise<SystemSettingsDto> {
if (input.developerMode !== undefined) {
await setBooleanSetting(SETTING_KEYS.developerMode, input.developerMode);
logger.info(`开发者模式已${input.developerMode ? '开启' : '关闭'}(操作人=${user.username})`);
}
return getSystemSettings();
}
export async function getAutostartStatus(): Promise<AutostartStatusDto> {
if (process.platform === 'win32') {
const cmd = startCmdPath();
if (!fs.existsSync(cmd)) {
return {
supported: false,
enabled: false,
mechanism: 'unsupported',
detail: '未检测到安装形态(缺少 start.cmd):开发环境或免安装目录不提供开机自启切换',
};
}
const query = runCommand('reg.exe', ['query', RUN_KEY_PATH, '/v', RUN_KEY_NAME]);
return {
supported: true,
enabled: query.ok,
mechanism: 'run-key',
detail: `注册表 HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\${RUN_KEY_NAME} → "${cmd}"`,
};
}
if (process.platform === 'linux') {
const unit = `/etc/systemd/system/${SYSTEMD_UNIT}.service`;
if (!fs.existsSync(unit)) {
return {
supported: false,
enabled: false,
mechanism: 'unsupported',
detail: `未找到 ${unit}(不是 systemd 安装形态)`,
};
}
const query = runCommand('systemctl', ['is-enabled', SYSTEMD_UNIT]);
return {
supported: true,
enabled: query.ok,
mechanism: 'systemd',
detail: `systemctl is-enabled ${SYSTEMD_UNIT}`,
};
}
return {
supported: false,
enabled: false,
mechanism: 'unsupported',
detail: `当前平台(${process.platform})不支持切换开机自启`,
};
}
export async function updateAutostart(input: UpdateAutostartInput): Promise<AutostartStatusDto> {
const current = await getAutostartStatus();
if (!current.supported) throw ApiError.badRequest(current.detail);
if (current.mechanism === 'run-key') {
const cmd = startCmdPath();
const result = input.enabled
? runCommand('reg.exe', ['add', RUN_KEY_PATH, '/v', RUN_KEY_NAME, '/t', 'REG_SZ', '/d', `"${cmd}"`, '/f'])
: runCommand('reg.exe', ['delete', RUN_KEY_PATH, '/v', RUN_KEY_NAME, '/f']);
if (!result.ok) throw ApiError.internal(`写入开机自启项失败:${result.output}`);
} else {
const result = runCommand('systemctl', [input.enabled ? 'enable' : 'disable', SYSTEMD_UNIT]);
if (!result.ok) {
throw ApiError.badRequest(
`systemctl ${input.enabled ? 'enable' : 'disable'} ${SYSTEMD_UNIT} 失败:${result.output}(服务是否以特权用户运行?)`,
);
}
}
logger.info(`服务端开机自启已${input.enabled ? '开启' : '关闭'}(机制=${current.mechanism})`);
return getAutostartStatus();
}
export async function getDiagnostics(): Promise<SystemDiagnosticsDto> {
const now = Date.now();
// 延迟 import:registry 会 import 本模块,顶层互相引用在 ESM 下会拿到未初始化的绑定
const { apiModules } = await import('../registry.js');
const [clientReleaseCount, onlineClientCount, settings] = await Promise.all([
prisma.clientRelease.count(),
prisma.clientDevice.count({
where: { lastHeartbeatAt: { gt: new Date(now - CLIENT_ONLINE_WINDOW_MS) } },
}),
getSystemSettings(),
]);
return {
version: APP_VERSION,
nodeVersion: process.versions.node,
platform: `${process.platform}-${process.arch}`,
uptimeSeconds: Math.round(process.uptime()),
databaseProvider: env.databaseProvider,
serverRoot: env.serverRoot,
dataDir: path.resolve(env.serverRoot, '..', 'data'),
clientReleaseCount,
onlineClientCount,
moduleCount: apiModules.length,
developerMode: settings.developerMode,
};
}
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, requireRole } from '../../middleware/auth.js';
import { authenticate, requirePermission } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -25,20 +25,30 @@ router.use(authenticate());
*/
/** GET /api/teachers?keyword= - 教师列表(教师管理页 + 分配协作教师时使用) */
router.get('/', requireRole('ADMIN'), validate({ query: listTeachersQuerySchema }), async (req, res) => {
const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功');
});
router.get(
'/',
requirePermission('teacher:manage'),
validate({ query: listTeachersQuerySchema }),
async (req, res) => {
const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功');
},
);
/** POST /api/teachers - 录入教师账号(仅管理员) */
router.post('/', requireRole('ADMIN'), validate({ body: createTeacherSchema }), async (req, res) => {
sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功');
});
router.post(
'/',
requirePermission('teacher:manage'),
validate({ body: createTeacherSchema }),
async (req, res) => {
sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功');
},
);
/** PATCH /api/teachers/:id - 编辑教师(姓名 / 用户名 / 角色) */
router.patch(
'/:id',
requireRole('ADMIN'),
requirePermission('teacher:manage'),
validate({ params: idParamSchema, body: updateTeacherSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
@@ -49,7 +59,7 @@ router.patch(
/** POST /api/teachers/:id/reset-password - 重置教师密码 */
router.post(
'/:id/reset-password',
requireRole('ADMIN'),
requirePermission('teacher:manage'),
validate({ params: idParamSchema, body: resetTeacherPasswordSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
@@ -59,11 +69,16 @@ router.post(
);
/** DELETE /api/teachers/:id - 删除教师账号(有班级职责/课程时拒绝,见 service) */
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await teacherService.deleteTeacher(id);
sendOk(res, { id }, '教师账号已删除');
});
router.delete(
'/:id',
requirePermission('teacher:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await teacherService.deleteTeacher(id);
sendOk(res, { id }, '教师账号已删除');
},
);
export const teachersModule = defineModule({
name: 'teachers',
@@ -11,7 +11,7 @@ import type {
} from './teachers.schemas.js';
/**
* 教师管理(**仅管理员**,路由层已用 requireRole('ADMIN') 兜住):
* 教师管理(**仅管理员**,路由层已用 requirePermission('teacher:manage') 兜住):
* 录入教师是管理员专属操作,班主任与科任老师都没有该权限。
* 学生名单在 students 模块,两个模块结构保持一致(列表 / 新建 / 编辑 / 重置密码 / 删除)。
*/
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -23,7 +23,7 @@ router.use(authenticate());
* - **写**:仅管理员 —— 周次是全校口径,改错会让所有班的课表整体错位;
* - `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。
*/
router.get('/', requireRole('ADMIN', 'TEACHER'), validate({ query: termQuerySchema }), async (req, res) => {
router.get('/', requirePermission('term:read'), validate({ query: termQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功');
@@ -32,7 +32,7 @@ router.get('/', requireRole('ADMIN', 'TEACHER'), validate({ query: termQuerySche
/** PUT /api/term - 保存逐周区间(整表覆盖,仅管理员) */
router.put(
'/',
requireRole('ADMIN'),
requirePermission('term:write'),
validate({ body: updateTermWeeksSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -51,7 +51,7 @@ router.put(
*/
router.post(
'/auto',
requireRole('ADMIN'),
requirePermission('term:write'),
validate({ body: autoTermWeeksSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -70,7 +70,7 @@ router.post(
*/
router.get(
'/holidays',
requireRole('ADMIN'),
requirePermission('term:write'),
validate({ query: holidayQuerySchema }),
async (req, res) => {
const { year } = validatedQuery<{ year?: number }>(req);
@@ -0,0 +1,220 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import {
API_PREFIX,
CLIENT_RELEASE_DIR,
clientReleaseDownloadUrl,
clientReleaseFileName,
compareVersions,
type ClientReleaseDto,
type ClientUpdateInfoDto,
} from '@classhelper/shared';
import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import { logger } from '../../lib/logger.js';
import type { FetchClientReleaseInput, RegisterClientReleaseInput } from './update.schemas.js';
/**
* 客户端安装包在服务端的缓存。
*
* 为什么要有这一层:教室机器常常**没有外网**,而"统一下发客户端更新"必须能落到
* 那些机器上。于是服务端替它们代下(或由管理员手工放一份)并缓存到
* `data/client-releases/<版本>/`,客户端再从 `GET /api/update/client/download/:version` 拉。
*
* 两个入口都**不传二进制**(服务端没有 multipart,也不打算为这一件事加):
* - `fetch`:服务端自己去 GitHub Releases 下(Web 端受 CSP 约束不能直连 GitHub,见 §5 第 52 条);
* - `register`:文件已由管理员放进磁盘(内网分发、U 盘、CI 产物),服务端只算 sha256 并登记。
*/
/** 代下大文件时的超时(安装包约 100MB,机房线路慢要留足) */
const FETCH_TIMEOUT_MS = 10 * 60 * 1000;
type ReleaseRow = {
id: string;
version: string;
fileName: string;
sha256: string;
size: number;
notes: string;
createdAt: Date;
updatedAt: Date;
};
function releaseRoot(): string {
// 与 database 模块的备份目录同口径:<serverRoot>/../data
const dir = path.join(env.serverRoot, '..', 'data', CLIENT_RELEASE_DIR);
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function releaseDir(version: string): string {
return path.join(releaseRoot(), version);
}
function releaseFilePath(version: string, fileName: string): string {
return path.join(releaseDir(version), fileName);
}
function toReleaseDto(row: ReleaseRow): ClientReleaseDto {
return {
version: row.version,
fileName: row.fileName,
size: row.size,
sha256: row.sha256,
notes: row.notes,
fileReady: fs.existsSync(releaseFilePath(row.version, row.fileName)),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
async function sha256Of(file: string): Promise<string> {
const hash = crypto.createHash('sha256');
await pipeline(fs.createReadStream(file), hash);
return hash.digest('hex');
}
/** GET /api/update/client/releases - 已缓存的安装包(按版本从新到旧) */
export async function listReleases(): Promise<ClientReleaseDto[]> {
const rows = (await prisma.clientRelease.findMany()) as unknown as ReleaseRow[];
return rows
.sort((a, b) => compareVersions(b.version, a.version))
.map((row) => toReleaseDto(row));
}
/** 服务端缓存的**最新**版本(推送更新时省略 version 就用它) */
export async function latestReleaseVersion(): Promise<string> {
const rows = (await prisma.clientRelease.findMany()) as unknown as ReleaseRow[];
const ready = rows.filter((row) => fs.existsSync(releaseFilePath(row.version, row.fileName)));
ready.sort((a, b) => compareVersions(b.version, a.version));
return ready[0]?.version ?? '';
}
/**
* 某个版本的下载信息(心跳响应里带给客户端)。
* 文件不在磁盘上时返回 null —— 客户端据此提示"服务端还没有这份安装包"。
*/
export async function resolveUpdateInfo(version: string): Promise<ClientUpdateInfoDto | null> {
const wanted = version.trim();
if (!wanted) return null;
const row = (await prisma.clientRelease.findUnique({ where: { version: wanted } })) as ReleaseRow | null;
if (!row) return null;
if (!fs.existsSync(releaseFilePath(row.version, row.fileName))) return null;
return {
version: row.version,
fileName: row.fileName,
size: row.size,
sha256: row.sha256,
notes: row.notes,
// 含 /api 前缀:客户端用「服务器地址 + 它」直接下载
url: `${API_PREFIX}/update/client/download/${encodeURIComponent(row.version)}`,
};
}
/** 绝对路径(下载路由用);文件不存在返回 null */
export async function resolveReleaseFile(version: string): Promise<{ file: string; fileName: string } | null> {
const row = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
if (!row) return null;
const file = releaseFilePath(row.version, row.fileName);
if (!fs.existsSync(file)) return null;
return { file, fileName: row.fileName };
}
/** 登记一份已经在磁盘上的安装包:算 sha256 / 大小后落库(可重复登记,会覆盖) */
export async function registerRelease(
userId: string,
input: RegisterClientReleaseInput,
): Promise<ClientReleaseDto> {
const version = input.version;
// 文件名默认走发布命名约定(与 GitHub Release、官网兜底直链一致)
const known = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
const fileName = input.fileName ?? known?.fileName ?? clientReleaseFileName(version);
const file = releaseFilePath(version, fileName);
if (!fs.existsSync(file)) {
throw ApiError.badRequest(
`没找到安装包文件「${fileName}」,请先把它放进 data/${CLIENT_RELEASE_DIR}/${version}/ 再登记`,
);
}
const stat = fs.statSync(file);
const sha256 = await sha256Of(file);
const row = (await prisma.clientRelease.upsert({
where: { version },
create: {
version,
fileName,
sha256,
size: stat.size,
notes: input.notes?.trim() ?? '',
createdBy: userId,
},
update: {
fileName,
sha256,
size: stat.size,
...(input.notes !== undefined ? { notes: input.notes.trim() } : {}),
},
})) as ReleaseRow;
logger.info(`登记客户端安装包 v${version}(${fileName},${stat.size} 字节,sha256=${sha256.slice(0, 12)}…)`);
return toReleaseDto(row);
}
/** 让服务端从 GitHub 拉一份安装包并登记(教室无外网时由服务端代下) */
export async function fetchReleaseFromGithub(
userId: string,
input: FetchClientReleaseInput,
): Promise<ClientReleaseDto> {
const version = input.version;
const fileName = clientReleaseFileName(version);
const url = clientReleaseDownloadUrl(version);
const target = releaseFilePath(version, fileName);
fs.mkdirSync(releaseDir(version), { recursive: true });
logger.info(`开始从 GitHub 拉取客户端安装包 v${version}:${url}`);
let response: Response;
try {
response = await fetch(url, {
redirect: 'follow',
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: { 'User-Agent': 'ClassHelper-Server' },
});
} catch (error) {
throw ApiError.badRequest(
`下载失败:${error instanceof Error ? error.message : String(error)}。` +
`可以把安装包手工放进 data/${CLIENT_RELEASE_DIR}/${version}/ 再点「登记」`,
);
}
if (!response.ok || !response.body) {
throw ApiError.badRequest(
`GitHub 上没有 v${version} 的客户端安装包(HTTP ${response.status},文件名应为 ${fileName})`,
);
}
try {
await pipeline(Readable.fromWeb(response.body as never), fs.createWriteStream(target));
} catch (error) {
fs.rmSync(target, { force: true });
throw ApiError.badRequest(`写入安装包失败:${error instanceof Error ? error.message : String(error)}`);
}
// 落盘后统一走登记:sha256 / 大小 / 落库都在同一处算
return registerRelease(userId, { version, fileName });
}
/** 删除一份缓存的安装包(元数据 + 磁盘文件) */
export async function deleteRelease(version: string): Promise<{ version: string }> {
const row = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
if (!row) throw ApiError.notFound('服务端没有这个版本的安装包');
await prisma.clientRelease.delete({ where: { version } });
fs.rmSync(releaseDir(version), { recursive: true, force: true });
logger.info(`删除客户端安装包 v${version}(${row.fileName})`);
return { version };
}
@@ -1,8 +1,17 @@
import { Router } from 'express';
import { sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser } from '../../middleware/auth.js';
import { sendCreated, sendOk, ApiError } from '../../lib/http.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import * as clientReleases from './client-releases.service.js';
import * as updateService from './update.service.js';
import {
clientReleaseVersionParamSchema,
fetchClientReleaseSchema,
registerClientReleaseSchema,
type FetchClientReleaseInput,
type RegisterClientReleaseInput,
} from './update.schemas.js';
const router = Router();
router.use(authenticate());
@@ -25,6 +34,86 @@ router.get('/check', async (req, res) => {
sendOk(res, await updateService.checkForUpdates({ force }), '更新检查完成');
});
/* ------------------------------- 客户端安装包的服务端缓存(统一下发更新的基础) */
/**
* GET /api/update/client/releases - 服务端已缓存了哪些客户端版本
*
* 教室机器没有外网,所以"统一推送更新"必须有这一层中继:服务端替它们把安装包拉下来,
* 客户端再从 `/client/download/:version` 取。
*/
router.get('/client/releases', requirePermission('client:manage'), async (_req, res) => {
sendOk(res, await clientReleases.listReleases(), '获取安装包列表成功');
});
/**
* POST /api/update/client/fetch - 让服务端从 GitHub 下载某个版本的客户端安装包并登记
*
* 服务端(Node)不受浏览器 CSP 约束,这是唯一能"一键代下"的地方;
* 下不动(学校出口没有外网)时退回 `POST /client/releases` 手工登记。
*/
router.post(
'/client/fetch',
requirePermission('client:update-push'),
validate({ body: fetchClientReleaseSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<FetchClientReleaseInput>(req);
sendCreated(res, await clientReleases.fetchReleaseFromGithub(user.sub, input), '安装包已下载并登记');
},
);
/**
* POST /api/update/client/releases - 登记一份已经放进磁盘的安装包
*
* 文件要求放在 `<serverRoot>/../data/client-releases/<版本>/` 下(文件名默认按发布约定推导)。
* 这条路径也是自动化验收用的:不需要外网、不需要传 100MB 二进制。
*/
router.post(
'/client/releases',
requirePermission('client:update-push'),
validate({ body: registerClientReleaseSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<RegisterClientReleaseInput>(req);
sendCreated(res, await clientReleases.registerRelease(user.sub, input), '安装包已登记');
},
);
/** DELETE /api/update/client/releases/:version - 删除缓存(元数据 + 磁盘文件) */
router.delete(
'/client/releases/:version',
requirePermission('client:update-push'),
validate({ params: clientReleaseVersionParamSchema }),
async (req, res) => {
const { version } = validatedParams<{ version: string }>(req);
sendOk(res, await clientReleases.deleteRelease(version), '安装包已删除');
},
);
/**
* GET /api/update/client/download/:version - 下载安装包(客户端自动更新走它)
*
* **故意不挂权限键**:下载方是教室里的班级端会话(角色 CLASS_DEVICE,权限集为空),
* 它代表那个班;真正的保护是"必须已登录 + 限流中间件"。
* `res.download` 自带 Content-Length / Range 支持,不需要另写流式助手。
*/
router.get(
'/client/download/:version',
validate({ params: clientReleaseVersionParamSchema }),
async (req, res) => {
const { version } = validatedParams<{ version: string }>(req);
const found = await clientReleases.resolveReleaseFile(version);
if (!found) throw ApiError.notFound('服务端没有缓存这个版本的安装包');
res.download(found.file, found.fileName, (error) => {
if (error) {
// 客户端中途取消下载也会走到这里,只记日志,不要往已发出的响应里再写东西
res.locals.downloadError = error instanceof Error ? error.message : String(error);
}
});
},
);
export const updateModule = defineModule({
name: 'update',
basePath: '/update',
@@ -0,0 +1,41 @@
import { z } from 'zod';
/**
* 安装包文件名/版本号只允许这些字符。
*
* 它们会被拼进磁盘路径(`data/client-releases/<版本>/<文件名>`),
* 所以必须挡住 `..`、`/`、`\` —— 否则一个 `version=../../` 就能写到仓库外面去。
*/
const SAFE_SEGMENT = /^[0-9A-Za-z._-]+$/;
export const clientReleaseVersionSchema = z
.string()
.trim()
.min(1, '请输入版本号')
.max(40, '版本号最多 40 个字符')
.refine((value) => SAFE_SEGMENT.test(value), {
message: '版本号只能包含字母、数字、点、下划线与短横线',
});
/** `POST /api/update/client/fetch`:让服务端替教室机器从 GitHub 拉一份安装包 */
export const fetchClientReleaseSchema = z.object({
version: clientReleaseVersionSchema,
});
/** `POST /api/update/client/releases`:登记一份已经放进磁盘的安装包(手工兜底与自动化用) */
export const registerClientReleaseSchema = z.object({
version: clientReleaseVersionSchema,
fileName: z
.string()
.trim()
.min(1)
.max(120)
.refine((value) => SAFE_SEGMENT.test(value), { message: '文件名只能包含字母、数字、点、下划线与短横线' })
.optional(),
notes: z.string().trim().max(500).optional(),
});
export const clientReleaseVersionParamSchema = z.object({ version: clientReleaseVersionSchema });
export type FetchClientReleaseInput = z.infer<typeof fetchClientReleaseSchema>;
export type RegisterClientReleaseInput = z.infer<typeof registerClientReleaseSchema>;
+4 -1
View File
@@ -1,8 +1,9 @@
import type { PermissionKey } from '@classhelper/shared';
import type { TokenPayload } from '../lib/jwt.js';
/**
* 扩展 Express 的 Request 类型。
* - auth:认证中间件解析出的当前用户
* - auth:认证中间件解析出的当前用户 + 生效权限集合
* - validatedQuery / validatedParams:校验中间件产出的类型安全数据
* (Express 5 中 req.query 是只读 getter,不能直接覆盖,因此单独存放)
*/
@@ -12,6 +13,8 @@ declare global {
auth?: {
token: string;
user: TokenPayload;
/** 生效权限集合(权限组并集,或角色默认模板;班级端为空集) */
permissions?: ReadonlySet<PermissionKey>;
};
validatedQuery?: unknown;
validatedParams?: unknown;