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
+12910 -453

No files matched your search

+233 -29
View File
@@ -118,10 +118,13 @@ website/ 产品官网(**纯静态、无构建步骤**,
-x64-server-setup.exe),它们是离线兜底链接的唯一依据。 -x64-server-setup.exe),它们是离线兜底链接的唯一依据。
packages/shared/src/ types.ts / constants.ts / permissions.ts / motion.ts / utils.ts / index.ts packages/shared/src/ types.ts / constants.ts / permissions.ts / motion.ts / utils.ts / index.ts
packages/server/ packages/server/
prisma/schema.prisma 数据模型(16 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts) prisma/schema.prisma 数据模型(24 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts)
prisma/migrations/ 16 个迁移(到 add_schedule_source 为止是旧模型;之后 6 个是 prisma/migrations/ 17 个迁移(到 add_schedule_source 为止是旧模型;之后 6 个是
2026-10-06 的角色与权限重构:add_student_table / add_student_grade_query / 2026-10-06 的角色与权限重构:add_student_table / add_student_grade_query /
add_grade_level / add_classhelper_heartbeat / add_archives / add_term_weeks) add_grade_level / add_classhelper_heartbeat / add_archives / add_term_weeks;
最后 1 个是 2026-10-10 的 add_permission_groups_and_clients:
权限组 3 表 + AppSetting + 客户端设备/命令/安装包 + AiAudit +
Notification.assignDate + User.careMode)
prisma/seed.ts 种子数据(会清空业务表后重建演示数据) prisma/seed.ts 种子数据(会清空业务表后重建演示数据)
prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串 prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串
scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,2026-10-06 实测 205 项) scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,2026-10-06 实测 205 项)
@@ -134,10 +137,12 @@ packages/server/
password schemas session term version(读 package.json) web-static password schemas session term version(读 package.json) web-static
src/middleware/ auth validate error security src/middleware/ auth validate error security
src/realtime/ socket.ts(房间)+ bus.ts(事件总线) src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
src/modules/ 17 个功能模块 + registry.ts + module.types.ts src/modules/ 21 个功能模块 + registry.ts + module.types.ts
(含 archives 毕业归档、term 学期周次) (含 archives 毕业归档、term 学期周次、permissions 权限组、
packages/web-admin/src/ api stores router layouts views(14) components(5) composables styles config.ts clients 客户端心跳与远控、system 开发者模式/开机自启、ai 大模型助手)
(含 ArchivesView 毕业归档、TermWeeksView 学期周次) packages/web-admin/src/ api stores router layouts views(18) components(5) composables styles config.ts
(含 ArchivesView 毕业归档、TermWeeksView 学期周次、PermissionsView 权限组、
ClientsView 客户端设备、AssistantView/aiSettingsView 大模型助手)
packages/desktop-client/ packages/desktop-client/
src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / update / logger / smoke src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / update / logger / smoke
src/preload/ contextBridge 白名单桥(index.ts + island.ts,**不暴露 ipcRenderer 本体**) src/preload/ contextBridge 白名单桥(index.ts + island.ts,**不暴露 ipcRenderer 本体**)
@@ -163,8 +168,8 @@ packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参
| 项 | 状态 | | 项 | 状态 |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Node | **v24.19.0**(`D:\nodejs\node.exe`;要求 ≥ 20.19,Prisma 7 硬性要求) | | Node | **v22.15.0**(`D:\Node.js\node.exe`;根 `engines` 要求 ≥ 20.19,Prisma 7 的 engine 约束是 `^20.19 \|\| ^22.12 \|\| >=24.0`,v22.15 都满足) |
| pnpm | 11.8.0(`packageManager` 锁定 11.8.0),宿主就是上面这个 node | | pnpm | 11.8.0(`packageManager` 锁定 11.8.0)。本机可用的本体在 `C:\Users\hck16\AppData\Local\node\corepack\v1\pnpm\11.8.0\bin\pnpm.cjs`;PATH 里那个 `pnpm.ps1` 是 DSH 桌面端的转发壳,**别绕它**(见下) |
| npm registry | `https://registry.npmjs.org/`(仓库内无 `.npmrc`,取全局配置) | | npm registry | `https://registry.npmjs.org/`(仓库内无 `.npmrc`,取全局配置) |
| 依赖 | 已装(707 包,`pnpm install` 实测 2m45s) | | 依赖 | 已装(707 包,`pnpm install` 实测 2m45s) |
| 后端 `.env` | 已由 `.env.example` 生成(**gitignored**),`JWT_SECRET` 为本机随机值,`TERM_START_DATE=2026-09-07` | | 后端 `.env` | 已由 `.env.example` 生成(**gitignored**),`JWT_SECRET` 为本机随机值,`TERM_START_DATE=2026-09-07` |
@@ -180,6 +185,43 @@ packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参
> **旧文档里的 `F:\classisland` / `F:\data` 已失效**:本机只有 `C:` 与 `D:`,`F:` 盘不存在。 > **旧文档里的 `F:\classisland` / `F:\data` 已失效**:本机只有 `C:` 与 `D:`,`F:` 盘不存在。
> 另有一份旧副本在 `C:\Users\胡\Desktop\classisland`(数据在它自己的 `data/` 下,2026-09-18 用过),当前以 `D:\Classisland` 为准。 > 另有一份旧副本在 `C:\Users\胡\Desktop\classisland`(数据在它自己的 `data/` 下,2026-09-18 用过),当前以 `D:\Classisland` 为准。
#### AI 会话里跑 pnpm:别撞上 DSH 的转发壳(2026-10-11 实测)
在 DSH 的 shell 工具里直接写 `pnpm xxx` 会拿到
`pnpm.ps1 : Node.js runtime not found. Please run DeepSeek Harness Desktop to install it first.`,
而同一台机器上人在普通终端里跑得好好的。原因与绕过办法:
- PATH 里排在前面的是 **DSH 桌面端的转发壳** `C:\Users\hck16\AppData\Local\deepseek-harness\bin\pnpm.ps1`
(`.cmd` 同理)。它有两道会挡住的判据:① 认 `$env:DSH_NODE` / PATH 上的 `node`,
**但要求 `major ≥ 24` 或 `22.19+`**,本机的 `v22.15.0` 卡在门外;
② 它自己的兜底 `%APPDATA%\dsh-tauri\runtime\node.exe` 与 bundled pnpm
`%APPDATA%\dsh-tauri\dependencies\pnpm\bin\pnpm.cjs` **本机都不存在**。
- **正确解法是喂给这个壳两个环境变量**(它自己就认这两个覆盖开关,见 shim 第 10、52 行),
把 `node` 与 `pnpm` 都指到本机真实的那份,然后就**照常写 `pnpm xxx`**:
```powershell
$env:DSH_NODE = 'D:\Node.js\node.exe'
$env:DSH_PNPM_BIN = 'C:\Users\hck16\AppData\Local\node\corepack\v1\pnpm\11.8.0\bin\pnpm.cjs'
pnpm typecheck # 与人在终端里跑完全等价
pnpm build:shared
```
- `DSH_NODE` 必须是**绝对路径**:壳拿到它就不再走"发现 PATH 上的 node 再卡版本"那段。
- `DSH_PNPM_BIN` 是**本体 .cjs 的路径**(不是目录,也不是 `pnpm.ps1`);壳会 `& $node $pnpmBin @args`
直接执行它。它同时替掉那条会失败的 `pnpmBin` 存在性检查。
- **`pnpm` 的嵌套调用**(`typecheck` = `build:shared && pnpm -r --no-bail typecheck`)靠这两个变量就够了:
子进程继承同一份环境,于是又落进同一个壳、又被同一对覆盖接住。
- **这三条都是踩过的坑**(同一个会话里依次试错,前两条各失败一次):
- 不要用 `DSH_PNPM`,那是"用户 pnpm 的可执行路径":壳转发给它时**仍然先过 ② 的版本门槛**,
而且**残留的 `DSH_PNPM` 会污染后面所有调用**——它与 `DSH_PNPM_BIN` 同时存在时壳会转发进
corepack 的 `pnpm.ps1`,那个壳又把它重置回不存在的 bundled 路径,于是原本能跑的
`typecheck` 报出 `[pnpm] pnpm not found`。要么别设,要么先 `Remove-Item Env:DSH_PNPM`。
- 也不要用 `node <pnpm.cjs> <script>` 绕开壳:只在**不带嵌套调用**的脚本上凑效
(`check:icons` / `lint` 能用),`typecheck` 这类内部还要再 `pnpm -r` 的会二次翻车。
- 反过来说:本机 **Node v22.15.0 够用**(根 `engines` ≥ 20.19,Prisma 7 允许 `^22.12`),
不需要为了跑命令去装 v24;上面那条 `runtime not found` 纯粹是壳的版本门槛,不是环境不满足要求。
### 从零重建环境(本机实测可行的顺序) ### 从零重建环境(本机实测可行的顺序)
```bash ```bash
@@ -251,7 +293,7 @@ pnpm build:classisland-plugin
| `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(`--exe` 指定路径) | | `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(`--exe` 指定路径) |
| `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理;实测 44 项) | | `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理;实测 44 项) |
| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 74 项) | | `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 74 项) |
| `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 全部 | | `pnpm dist:server` / `dist:win` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 全部 |
| `pnpm dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 | | `pnpm dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 |
| `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 插件 / 官网 / 文档站 / 界面内品牌标) | | `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 插件 / 官网 / 文档站 / 界面内品牌标) |
| `pnpm version:check` | 逐处列出并比对 14 个落点的版本号,不一致即 exit 1(见 §4.4「版本迭代」) | | `pnpm version:check` | 逐处列出并比对 14 个落点的版本号,不一致即 exit 1(见 §4.4「版本迭代」) |
@@ -281,20 +323,37 @@ pnpm build:classisland-plugin
releases/ releases/
client/<版本>/ client/<版本>/
安装包/ ClassHelper-<版本>-x64-setup.exe / -portable.exe(+ .blockmap) 安装包/ ClassHelper-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
免安装/ electron-builder 的 win-unpacked 内容
构建中间/ builder-*.yml、*.nsis.7z 构建中间/ builder-*.yml、*.nsis.7z
server/<版本>/ server/<版本>/
安装包/ ClassHelper 服务端-<版本>-x64-setup.exe 安装包/ ClassHelper 服务端-<版本>-x64-setup.exe
免安装/ classhelper-server 免安装目录(含 .env、node.exe、data/、logs/) linux-x64/ classhelper-server-linux-x64-<版本>.tar.gz + .sha256
linux-x64/ classhelper-server-linux-x64-<版本>.tar.gz + .sha256 + 同内容解包目录
构建中间/ server-installer.generated.nsi 构建中间/ server-installer.generated.nsi
classisland-plugin/<版本>/ classisland-plugin/<版本>/
安装包/ ClassHelper.ClassIslandPlugin.cipx 安装包/ ClassHelper.ClassIslandPlugin.cipx
免安装/ ClassHelper.ClassIslandPlugin/(解压即用,丢进 Plugins 目录即可加载)
checksums.md checksums.md
SHA256SUMS-<版本>.txt 跨组件哈希清单(人读,由发布流程维护) SHA256SUMS-<版本>.txt 跨组件哈希清单(人读,由发布流程维护)
``` ```
**`releases/` 里只放交付物,不放"解包出来的那一份"。** 这条是硬约定,2026-10-11 统一过一遍:
| 曾经有的目录 | 它是什么 | 现在 |
| --- | --- | --- |
| `client/<版本>/免安装/` | electron-builder 的 `win-unpacked`(约 323MB) | 只在打包过程中存在,生成两个 exe 后由 `tidyArtifacts()` 删除 |
| `server/<版本>/免安装/` | `pnpm dist:server` 组装的运行时(约 335MB) | 组装到 `.cache/server-staging/<版本>/win/`,打完 NSIS 即删 |
| `server/<版本>/linux-x64/classhelper-server-linux-x64/` | tar.gz 的解包留档(约 265MB) | 打完 tar.gz 即删(内容与 tar 完全一样) |
| `classisland-plugin/<版本>/免安装/` | `.cipx` 的解压副本 | 不再产出(`.cipx` 本身就是 zip,解压即用) |
理由:这些都是 300MB 量级的**中间物**,而且长得跟交付物一样像"该拷走的东西" ——
五个客户端版本 + 七个服务端版本堆下来就是 4.8GB,其中 3.4GB 是重复的中间物。
`-portable.exe` 是自解压单文件,服务端安装程序内置 Node,**用户侧一个解包目录都不需要**。
需要临时看一眼解包内容时,直接解压 portable,或按下面那张表的路径去 `.cache/` 找。
(三个脚本仍会把 `构建中间/` 留在版本目录里 —— `builder-*.yml`、`*.nsis.7z`、
`server-installer.generated.nsi`,那是每个版本几 KB 的打包脚本与元数据,**保留**。
实测 8 个版本目录合计约 50 KB。)
**Linux 包同理**:`.tar.gz` 旁边的同名解包目录(约 265MB)已不再保留,需要看内容就
`tar -xzf` 一份到 `.cache/` 里(见 §5 第 59 条 ④ 的命名约定)。
版本号取自根 `package.json`(插件用 `manifest.yml` 的前三段,与产品版本对齐); 版本号取自根 `package.json`(插件用 `manifest.yml` 的前三段,与产品版本对齐);
重新打包只清**当前版本目录**里的产物,历史版本原样保留 —— 所以同版本多次构建留下的 重新打包只清**当前版本目录**里的产物,历史版本原样保留 —— 所以同版本多次构建留下的
不同产物要自己改名归档(例如 `1.0.0-重构建20261005/`),别把上一次的覆盖掉。 不同产物要自己改名归档(例如 `1.0.0-重构建20261005/`),别把上一次的覆盖掉。
@@ -302,14 +361,20 @@ releases/
```bash ```bash
pnpm dist:server # 服务端 Windows(首次会跑一次 npm install;重试可加 --reuse-deps 跳过) pnpm dist:server # 服务端 Windows(首次会跑一次 npm install;重试可加 --reuse-deps 跳过)
pnpm dist:server:linux # Linux 安装包(**必须在 Linux 上跑**,见 §5 第 59 条 ④) pnpm dist:server:linux # Linux 安装包(**必须在 Linux 上跑**,见 §5 第 59 条 ④)
pnpm dist:win # 客户端:安装包 + 免安装目录;dist:dir 只出免安装目录 pnpm dist:win # 客户端:安装包 + 便携版
pnpm dist:classisland-plugin pnpm dist:classisland-plugin
``` ```
`dist-win.mjs` 用 `--config.directories.output` 覆盖 electron-builder 自己的输出目录,打包成功后 `dist-win.mjs` 用 `--config.directories.output` 覆盖 electron-builder 自己的输出目录,打包成功后
再把原始布局整理成上面的分层(`win-unpacked/` → `免安装/`、`*.exe` → `安装包/`、`*.yml` → `构建中间/`); 再把原始布局整理成上面的分层(`*.exe` → `安装包/`、`*.yml` → `构建中间/`),**最后删掉 `win-unpacked/`**;
显式传 `-c.directories.output=<路径>` 时不再覆盖,整理以传入路径为准。 显式传 `-c.directories.output=<路径>` 时不再覆盖,整理以传入路径为准。
**交付物的完整性标签修正(§7 第 7 条)也挂在这条收尾链路上,别把 `tidyArtifacts` 之后的 两条别动的顺序约束:
- **删 `win-unpacked` 必须排在整理之后**:portable 目标就是拿这份解包结果做自解压包,
提前删会让 `-portable.exe` 直接失败。
- **输出目录的完整性标签要在打包前压回 Low**:上一轮收尾把它标成 Medium 之后,7za(自身带 Low)
读不动那些目录,pack 时报 `cannot open file ... Access is denied`(实测踩过)。
**交付物的完整性标签修正(§7 第 7 条)挂在收尾链路上,别把 `tidyArtifacts` 之后的
`normalizeIntegrityLabel(destDir)` 拆掉。** `normalizeIntegrityLabel(destDir)` 拆掉。**
### 版本迭代(每一次 releases,不是每一次提交) ### 版本迭代(每一次 releases,不是每一次提交)
@@ -359,7 +424,8 @@ pnpm build && pnpm verify:web
# ④ 打安装包(产物落在 releases/<组件>/<版本>/) # ④ 打安装包(产物落在 releases/<组件>/<版本>/)
pnpm dist:all pnpm dist:all
pnpm verify:packaged --exe "releases/client/<版本>/免安装/ClassHelper.exe" # 对**打包后的副本**复验:便携版就是自解压单文件,直接拿它当被测副本最省事
pnpm verify:packaged --exe "releases/client/<版本>/安装包/ClassHelper-<版本>-x64-portable.exe"
# ⑤ 整理跨组件哈希清单 releases/SHA256SUMS-<版本>.txt(人读;Linux 那一行由 CI 合并进来) # ⑤ 整理跨组件哈希清单 releases/SHA256SUMS-<版本>.txt(人读;Linux 那一行由 CI 合并进来)
# ⑥ 提交并推上去 # ⑥ 提交并推上去
@@ -446,9 +512,9 @@ https://lovechengke.github.io/ClassHelper/docs/ ← 文档站(docfx 构建
## 5. 架构硬约定(改代码前必须知道) ## 5. 架构硬约定(改代码前必须知道)
1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules/<name>/`(`*.module.ts` 路由、 1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules/<name>/`(`*.module.ts` 路由、
`*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 17 个:`auth / classes / courses / schedules / `*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 21 个:`auth / classes / courses / schedules /
homeworks / notifications / calls / imports / integrations / grades / students / teachers / archives / homeworks / notifications / calls / imports / integrations / grades / students / teachers / archives /
term / dashboard / database / update`。 term / dashboard / database / update / permissions / clients / system`。
新增/下线功能**只改 `src/modules/registry.ts`**(加一行 / 改 `enabled: false`),不要动 `app.ts`。 新增/下线功能**只改 `src/modules/registry.ts`**(加一行 / 改 `enabled: false`),不要动 `app.ts`。
2. **统一响应与校验。** 用 `src/lib/http.ts` 的 `sendOk / sendCreated / …` 返回,用 2. **统一响应与校验。** 用 `src/lib/http.ts` 的 `sendOk / sendCreated / …` 返回,用
`middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。 `middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。
@@ -1149,6 +1215,90 @@ term / dashboard / database / update`。
联网拉取调休建议(`GET /api/term/holidays`)**只给建议、不改数据**,且 联网拉取调休建议(`GET /api/term/holidays`)**只给建议、不改数据**,且
**机房无外网时 `ok:false` 是正常结果**,失败结果同样进缓存(否则断网点一次要白等一个超时)。 **机房无外网时 `ok:false` 是正常结果**,失败结果同样进缓存(否则断网点一次要白等一个超时)。
67. **功能级准入看「权限键」,角色只是默认模板(2026-10 权限组重构)。**
路由上挂 `requirePermission('…')`(AND)/ `requireAnyPermission(…)`,键目录在
`packages/shared/src/permissions.ts`(`PERMISSION_KEYS` 39 键 / `PERMISSION_LABELS` /
`PERMISSION_CATEGORIES` / `PERMISSION_DEFAULT_ROLES` / `ROLE_DEFAULT_PERMISSIONS`)。
有效权限由 `packages/server/src/lib/permissions.ts` 的 `loadEffectivePermissions()` 算:
ADMIN=全集且 `permission:manage`/`system:manage` 不可剥夺;CLASS_DEVICE=空集(不受分组影响);
有分组=并集(**可以比角色默认更少**,这就是"能收权");无分组=角色默认模板。
它**每请求实时查库**(不缓存),所以改完分组立刻生效。
**不可松的边界**:权限组只管功能/路由级;班级维度的科目规则(`assertCanManageSubjectContent`)
故意不可被权限组覆盖,否则「班主任布置别人科目作业」的老 bug 会从新路长回来。
Web 端门控是「`meta.roles` 粗筛 + `meta.permission` 细筛」(`beforeEach` 里
`await auth.ensurePermissions()` 再 `auth.can(key)`,不通过 fail-closed),
权限矩阵与生效规则见 `docs/dev/permissions.md`。
68. **客户端设备身份是 `deviceKey`,它不接受外部改写。** 客户端配置里的 `deviceKey` 是首次启动生成的
UUID,服务端按它 upsert `ClientDevice`;`saveConfig()` **拒绝**渲染进程改写它(改了就等于换一台机器),
退出登录走 `clearConfig()` 时也**保留**它(连同 `autoLaunch`/`autoUpdate`/`developerMode`/`careMode`
与待安装的更新记录)。上报走「客户端定时拉」而不是服务端推:
`POST /api/clients/heartbeat`(30 秒一次,首报延迟 3 秒),响应里带回「目标版本 + 一条待执行命令」;
在线判定窗口 `CLIENT_ONLINE_WINDOW_MS`=90 秒。三条边界:**没有 token 就不上报**(未登录静默跳过)、
**401/403 退避 5 分钟**、**冒烟默认不上报**(要验这条链路才设 `ELECTRON_SMOKE_CLIENT_AGENT=1`)。
69. **远控命令要两端各确认一次,而且客户端**必须**回执。** 服务端 `POST /api/clients/commands` 只对
`remote:control`(默认仅 ADMIN)开放,动作限 `shutdown`/`restart`/`logoff`/`lock`,有效期
`CLIENT_COMMAND_EXPIRE_MS`=10 分钟;客户端收到后弹**默认选中「取消」**的对话框(60 秒未确认按取消处理),
执行只支持 Windows(`shutdown.exe /s|/r /t 0`、`/l`、`rundll32 user32.dll,LockWorkStation`),
然后 `POST /api/clients/commands/:id/ack`(body 带 `deviceKey`,服务端据此校验归属,重复回执幂等)。
同一台机器上**同一条命令只弹一次**(内存去重),回执失败只暂存重试、不重复弹窗。
70. **客户端安装包由服务端下载并缓存,教室机不直连 GitHub。** 原因有两条:Web 端 CSP
(`connect-src 'self' ws wss …`)不允许浏览器直连 GitHub,而教室机常常根本没有外网。
所以 `POST /api/update/client/fetch` 由**服务端**下载到 `<serverRoot>/../data/client-releases/<版本>/`
并登记 sha256+size,`GET /api/update/client/download/:version` **故意不挂权限键**(班级端要能下载)。
版本号一律先用 `^[0-9A-Za-z._-]+$` 校验(防路径穿越)。客户端自动安装的三条护栏:
**没有 sha256 就拒收**、**必须等到"非上课 + 主窗口隐藏满 `CLIENT_UPDATE_IDLE_MS`(5 分钟)"**、
冒烟模式**永不安装**。
71. **通知与作业一样按「所属日期」归档,批量发布在服务端做。** `Notification.assignDate` 与
`Homework.assignDate` 同口径(`dayKeyLocal()`,禁止 UTC 切片);列表支持 `date`/`from`/`to`/`createdBy`,
另有 `GET /notifications/days` 与 `/notifications/creators` 供"按天/按发布人归档"的界面用。
批量走 `POST /notifications/bulk` 与 `POST /homeworks/bulk`:**单班失败不影响整批**
(逐班返回 `BulkPublishClassResult`),作业的 `subjectName` 跨班**按名匹配**已有课程,
**找不到就整班记失败,绝不自动建课**(自动建课会把创建者写成任课老师,等于越权改任教关系)。
72. **大模型助手只解析、不落库。** `POST /api/ai/interpret` 返回的是**待确认**的结构化动作
(`create_homework` / `create_notification` / `call_student` / `unknown` + `params` + `preview`),
真正落库由前端确认后调**既有**端点 —— 于是权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用。
模型给的 `params` **必须用与手动入口同一份 zod 再校验一次**(模型输出不可信)。
API Key 用 `packages/server/src/lib/secrets.ts` 的 AES-256-GCM(密钥由 `JWT_SECRET` 经 scrypt 派生)
加密后存 `AppSetting`,**只进不出**(GET 只回 `hasApiKey` 与尾 4 位)。
`provider='mock'` 是离线确定性解析,**自动化验收一律用它**(§5 第 52 条:不许访问外网);
每次解析写一条 `AiAudit`(不含密钥)。
73. **开发者模式是服务端的一个内存缓存开关。** 真值存在 `AppSetting`(键 `system.developerMode`),
但热路径(错误中间件)不可能每请求查库,所以 `packages/server/src/lib/settings.ts` 用
`isDeveloperMode()` / `refreshDeveloperMode()` 缓存它:**启动时刷一次、管理员改设置时更新**。
开启后只多做一件事:500 的 `ApiError` 在 `details` 里附 `error.stack`(见 `middleware/error.ts`)。
`GET /api/system/diagnostics` **绝不回传任何密钥**。开关与诊断接口都要求 `system:manage`。
74. **开机自启动必须"可选择 + 运行期可改"。** 安装器只负责**默认值**:
Windows 在 `scripts/nsis/server-installer.nsi` 里是**组件页上一个独立的 Section**(默认勾选),
Linux 在 `deploy/install.sh`;装完之后一律走 `GET/PUT /api/system/autostart`
(Windows 改 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ClassHelperServer`,
Linux `systemctl enable/disable classhelper`)。**非安装形态(开发机 / 免安装目录 / 容器)必须返回
`supported:false` 并说明原因** —— 别往开发机的注册表里写自启项。
75. **服务端菜单回缩与关怀模式:折叠只用 CSS,放大只动字号。** 侧栏折叠**不要用 `el-menu` 的
`:collapse`**(它会重建 DOM,与桌面客户端侧栏踩过的坑同源),而是 CSS 收拢宽度 + 折叠态文字
「既无宽度又不透明」;覆盖 Element Plus 缩进时要**照抄它自己的选择器形状**(0-5-0/0-6-0),
写低特异度的选择器会静默失效。关怀模式(`html.care-mode` + `--ch-font-scale`)只放大**正文字号与控件**,
**不动任何几何令牌**(圆角/阴影/侧栏宽),否则桌面客户端侧栏导轨的折叠几何断言会红。
76. **客户端上线补弹离线期间收到的消息只做一次。** 起点(`localStorage` 的
`classhelper.offlineSince`)**只在"可达 → 不可达"的边界写**(不在每次失败上覆盖,否则窗口被无限推迟),
恢复时按起点筛未读通知、取**最近 5 条**并按时间升序推给灵动岛,推完清起点(同一次离线只弹一次)。
**冒烟模式直接跳过**:冒烟会反复断开/恢复网络,补弹会与注入的合成通知互相打乱队列,
把既有的条数/顺序/列表高度断言变成随机红。
77. **客户端崩溃自愈只在"没在上课、没人看着"时才重启进程。** `render-process-gone` 与
`unresponsive`(超 10 秒)先**静默重建主窗口**(10 分钟内最多 3 次),
`uncaughtException`/`unhandledRejection` 也只在 `isIdle()`(非上课 + 无可见窗口)时才
`app.relaunch()`;**冒烟模式绝不重启**(会杀掉验证实例)。
自愈模块在 `packages/desktop-client/src/main/crash-guard.ts`,限流与 `isIdle` 判据别放宽。
## 6. 代码风格 ## 6. 代码风格
- **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀** - **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀**
@@ -1226,12 +1376,17 @@ term / dashboard / database / update`。
判据与修法(都是实测过的): 判据与修法(都是实测过的):
```bash ```bash
# 判据:看交付物(以及 node_modules/electron/dist)有没有 Low 标签 # 判据:看交付物(以及 node_modules/electron/dist)有没有 Low 标签。
icacls packages\desktop-client\release\win-unpacked\ClassHelper.exe | findstr Mandatory # **正常状态是"没有 Mandatory Label 这一行"**(= 默认 Medium);出现
# `Mandatory Label\Low Mandatory Level` 才是坏的那个。所以别去找"是不是 Medium"。
# 目标就是下面这两个 exe —— 中间物 win-unpacked 打完就删了(§4「交付产物归集」)。
icacls "releases\client\<版本>\安装包\ClassHelper-<版本>-x64-setup.exe" | findstr Mandatory
icacls "releases\client\<版本>\安装包\ClassHelper-<版本>-x64-portable.exe" | findstr Mandatory
# 修法:把交付物改成 Medium。dist-win.mjs 收尾会自动做,手工补救用: # 修法:把交付物改成 Medium。dist-win.mjs 收尾会自动做,手工补救用:
icacls packages\desktop-client\release /setintegritylevel Medium /T /C icacls "releases\client\<版本>" /setintegritylevel Medium /T /C
# 目录自身要还原成继承,否则下次构建时自带 Low 标签的 7za / makensis 写不进去 # 目录自身要还原成继承,否则下次构建时自带 Low 标签的 7za / makensis 写不进去
icacls packages\desktop-client\release /setintegritylevel "(OI)(CI)Low" #(这一步 dist-win.mjs 现在在**打包前**就做了,见 §4)
icacls "releases\client\<版本>" /setintegritylevel "(OI)(CI)Low"
``` ```
连带的三条经验:**归档内部的载荷标签无所谓**(装到仓库外得到的是默认 Medium 文件), 连带的三条经验:**归档内部的载荷标签无所谓**(装到仓库外得到的是默认 Medium 文件),
@@ -1294,11 +1449,12 @@ term / dashboard / database / update`。
同一个排查动作 —— 先把 `TEMP` 指到仓库内一个**真实存在的 Windows 绝对路径**再重试: 同一个排查动作 —— 先把 `TEMP` 指到仓库内一个**真实存在的 Windows 绝对路径**再重试:
- `pnpm dist:server` 报 `Internal compiler error #12345: error creating mmap the size of N`:模板用了 - `pnpm dist:server` 报 `Internal compiler error #12345: error creating mmap the size of N`:模板用了
`SetCompressor /SOLID lzma`,makensis 会在 **`%TEMP%` 所在的盘**建一个上百 MB 的 mmap 临时文件,盘紧就失败 `SetCompressor /SOLID lzma`,makensis 会在 **`%TEMP%` 所在的盘**建一个上百 MB 的 mmap 临时文件,盘紧就失败
(免安装目录其实已生成好,只是最后一步打不出安装包)。加 `--reuse-deps` 还能跳过依赖安装。 (组装出来的运行时其实已经落在 `.cache/server-staging/<版本>/win/`,只是最后一步打不出安装包)。
加 `--reuse-deps` 还能跳过依赖安装。
- `pnpm dist:win` 报 `!tempfile: Unable to create temporary file!` / `Error in macro _Switch`:**不是空间不足**, - `pnpm dist:win` 报 `!tempfile: Unable to create temporary file!` / `Error in macro _Switch`:**不是空间不足**,
而是 Git Bash 里 `TEMP=TMP=/tmp` —— makensis 是原生程序,把 `/tmp` 当成「当前盘根下的 tmp」解析成 而是 Git Bash 里 `TEMP=TMP=/tmp` —— makensis 是原生程序,把 `/tmp` 当成「当前盘根下的 tmp」解析成
`D:\tmp`,该目录不存在。`/c` 有 79G、`/d` 有 124G 也会照样失败(2026-09-30 实测)。 `D:\tmp`,该目录不存在。`/c` 有 79G、`/d` 有 124G 也会照样失败(2026-09-30 实测)。
`win-unpacked` 与 `*.nsis.7z` 此时都已生成(整理进 `安装包/`、`免安装/` 的那一步在打包 `win-unpacked` 与 `*.nsis.7z` 此时都已生成(整理进 `安装包/`、删 `win-unpacked/` 的那一步在打包
成功之后才跑,失败时还没执行),**只需重跑,不必重建前端**: 成功之后才跑,失败时还没执行),**只需重跑,不必重建前端**:
```bash ```bash
@@ -1412,12 +1568,15 @@ Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland
并用 `pnpm verify:packaged --exe "<客户端路径>"` 对**打包后的副本**复验 —— 并用 `pnpm verify:packaged --exe "<客户端路径>"` 对**打包后的副本**复验 ——
开发产物通过不等于用户机器上的副本通过。 开发产物通过不等于用户机器上的副本通过。
**改过 `pruneRuntime()`(§5 第 55 条)时,还要对免安装目录本身跑一遍**(开发形态跑通不代表裁剪后跑通)。 **改过 `pruneRuntime()`(§5 第 55 条)时,还要对裁剪后的产物本身跑一遍**(开发形态跑通不代表裁剪后跑通)。
从 `releases/server/<版本>/免安装/` 起,用**自带的 node.exe**: 产物有两处现成的,**都带裁剪**:装好的服务端 `%LOCALAPPDATA%\Programs\ClassHelperServer`,
或打包过程中的组装目录 `.cache/server-staging/<版本>/win/`(`dist-server.mjs` 在成功收尾时会删掉它,
所以要么在打包失败时留用、要么临时注掉那一步 `fs.rmSync(staging, ...)`)。
从那一层起,用**自带的 node.exe**:
```bash ```bash
# ① 起服务:探针通 + 能真实查表(AUTO_MIGRATE 会建库;PORT 用环境变量避开开发端口) # ① 起服务:探针通 + 能真实查表(AUTO_MIGRATE 会建库;PORT 用环境变量避开开发端口)
cd releases/server/<版本>/免安装 && PORT=4100 ./node.exe server/dist/index.js cd %LOCALAPPDATA%\Programs\ClassHelperServer && set PORT=4100 && node.exe server/dist/index.js
curl.exe -s --noproxy '*' http://127.0.0.1:4100/healthz curl.exe -s --noproxy '*' http://127.0.0.1:4100/healthz
curl.exe -s --noproxy '*' -X POST http://127.0.0.1:4100/api/auth/login \ curl.exe -s --noproxy '*' -X POST http://127.0.0.1:4100/api/auth/login \
-H "content-type: application/json" -d '{"username":"admin","password":"admin123"}' -H "content-type: application/json" -d '{"username":"admin","password":"admin123"}'
@@ -1437,3 +1596,48 @@ DATABASE_PROVIDER=sqlite DATABASE_URL="file:./prune-test.db" \
这里的目标是脚本刚建出来的空临时库,不是真实数据。) 这里的目标是脚本刚建出来的空临时库,不是真实数据。)
自测本地接口记得绕过代理(见 §7 第 1 条)。 自测本地接口记得绕过代理(见 §7 第 1 条)。
---
## 9. 工作区维护(2026-10-11 整理留档)
工作区会随着每次发版慢慢长胖,而且长得**很像正经产物**。这一节记录清理的结论与边界,
下次觉得"盘要满了"直接照这里做,不要临场发挥。
### 这次删了什么、留了什么
| | 内容 | 量 |
| --- | --- | --- |
| 删 | `releases/**/免安装/`(客户端 4 版 + 服务端 4 版 + 插件 4 版) | 2.57 GB |
| 删 | `releases/server/{0.1.0,1.0.0,1.0.0-重构建20261005}`(旧产品名"班级小助手"时期的孤立目录) | 204 MB |
| 删 | `releases/server/{1.1.0,1.1.2}/linux-x64/classhelper-server-linux-x64/`(tar.gz 的解包留档) | 530 MB |
| 删 | `releases/server/1.1.2/安装包/班级小助手服务端-1.1.2-x64-setup.exe`(改名前的重复包) | 62 MB |
| 删 | `.cache/` 里的草稿:smoke 日志与 JSON、`ui-smoke-profile`、`pages`、`tmp`、截图目录、`ci-*.html/css`、`beui-*`、`release-notes-*`、一次性脚本、`dev.db.bak-*` | 276 MB |
| 删 | 根目录 `ClassHelper video.cachedbg3.log`(0 字节)、`.acl-recovery/`(沙箱 ACL 修复的一次性备份) | ~0 |
| 删 | `packages/classisland-plugin/{bin,obj,cipx}`(构建产物,gitignored,重跑即生成) | 1.1 MB |
| **留** | `.cache/{nuget,npm-cache,dotnet-home,mingit}`(工具缓存,按用户要求一个都不删) | 2.4 GB |
合计释放 **约 3.7 GB**,`releases/` 从 4.8 GB 降到 1.4 GB,其余是安装包本体。
### 边界(别删过界)
- **`releases/` 里的安装包本体与 `SHA256SUMS-*.txt` 一律留着**:每个都在 GitHub Release 上有对应资产
(v1.0.0 无 Linux 包,v1.1.0/v1.2.0 有),删了要重新下载才能对齐哈希。
- **`.cache/{nuget,npm-cache,dotnet-home,mingit}` 不删** —— 它们是工具缓存,删了要重新下载;
`dotnet-home` 里还有 docfx(`pnpm docs:build` 依赖)。
- **`packages/*/dist` 与 `packages/server/src/generated` 不删**:删了要重跑构建 + `db:generate`,
而且 `verify:*` 全部要重新准备前置产物。
- **`.cache/dev.db.bak-*` 这类"带日期的备份"删之前想一下**:本次删掉的是 2026-10-06 的
`dev.db` 快照,演示数据可用 `pnpm db:reset` 重建,所以算草稿。别把 `packages/server/prisma/` 下的库当草稿。
- **`.zcode/plans/` 与 `.claude/`** 是历史工具目录,内容已过时但不占空间,留着即可。
### 怎么不重新长回来
产出侧已经改成"只留交付物"(§4「交付产物归集」的硬约定)。另外接手时要清楚:
- **别把 `--reuse-deps` 的复用目录当成产物找回**:服务端的组装中间物现在在
`.cache/server-staging/<版本>/`,打完 `dist-server.mjs` 就会删掉它;想留一份能直接跑的副本,
就在打包过程中拷出来(或直接跑 `pnpm --filter @classhelper/server start`)。
- **别在 `releases/` 下留"临时跑一下"的东西**:需要看解包内容就解压 `-portable.exe` 到 `%TEMP%`。
- 判断某个目录能不能删,就问一句:**"它能不能用仓库里的脚本一条命令重建?"** 能就删。
+1 -1
View File
@@ -23,7 +23,7 @@ services:
context: .. context: ..
dockerfile: deploy/Dockerfile dockerfile: deploy/Dockerfile
target: runtime target: runtime
image: classhelper-server:${APP_VERSION:-1.2.0} image: classhelper-server:${APP_VERSION:-1.3.0}
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
+1 -1
View File
@@ -60,7 +60,7 @@ services:
context: .. context: ..
dockerfile: deploy/Dockerfile dockerfile: deploy/Dockerfile
target: runtime target: runtime
image: classhelper-server:${APP_VERSION:-1.2.0} image: classhelper-server:${APP_VERSION:-1.3.0}
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
+12
View File
@@ -44,6 +44,7 @@
# --node-tarball <url|路径> 直接用指定的 Node 压缩包(内网离线场景) # --node-tarball <url|路径> 直接用指定的 Node 压缩包(内网离线场景)
# --with-nginx 顺带放一份 Nginx 反代配置(不自动申请证书) # --with-nginx 顺带放一份 Nginx 反代配置(不自动申请证书)
# --open-firewall 按检测到的防火墙放行端口 # --open-firewall 按检测到的防火墙放行端口
# --no-autostart 不设开机自启(默认设为自启;装完仍可 systemctl enable 改回来)
# --yes 全部用默认值,不再交互(CI / 验收用) # --yes 全部用默认值,不再交互(CI / 验收用)
# #
# 幂等:重复执行 = 覆盖安装。已有 config.env(含 JWT_SECRET)与 data/ 一律保留,不重置密钥。 # 幂等:重复执行 = 覆盖安装。已有 config.env(含 JWT_SECRET)与 data/ 一律保留,不重置密钥。
@@ -91,6 +92,10 @@ NODE_DIR_NAME="runtime/node"
WITH_NGINX=0 WITH_NGINX=0
OPEN_FIREWALL=0 OPEN_FIREWALL=0
# 是否设为开机自启(systemd enable)。需求:「支持选择开机自启动」——
# 安装器只负责**默认值**,装完之后随时可以用 `systemctl disable/enable classhelper` 改
# (Windows 侧对应 Web 管理端「系统设置」里的开关,见 PUT /api/system/autostart)。
AUTOSTART=1
ASSUME_YES=0 ASSUME_YES=0
CHECK_ONLY=0 CHECK_ONLY=0
UNINSTALL=0 UNINSTALL=0
@@ -231,6 +236,8 @@ parse_args() {
--node-tarball) NODE_TARBALL="${2:-}"; NODE_SOURCE="tarball"; shift 2 ;; --node-tarball) NODE_TARBALL="${2:-}"; NODE_SOURCE="tarball"; shift 2 ;;
--with-nginx) WITH_NGINX=1; shift ;; --with-nginx) WITH_NGINX=1; shift ;;
--open-firewall) OPEN_FIREWALL=1; shift ;; --open-firewall) OPEN_FIREWALL=1; shift ;;
--autostart) AUTOSTART=1; shift ;;
--no-autostart) AUTOSTART=0; shift ;;
--install-docker) INSTALL_DOCKER=1; shift ;; --install-docker) INSTALL_DOCKER=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;; --yes|-y) ASSUME_YES=1; shift ;;
--force) FORCE=1; shift ;; --force) FORCE=1; shift ;;
@@ -477,6 +484,7 @@ collect_answers() {
fi fi
if ask_yes_no "顺带放一份 Nginx 反向代理配置到 ${CONFIG_DIR}/nginx.conf?" "n"; then WITH_NGINX=1; fi if ask_yes_no "顺带放一份 Nginx 反向代理配置到 ${CONFIG_DIR}/nginx.conf?" "n"; then WITH_NGINX=1; fi
if ask_yes_no "按检测到的防火墙放行端口 ${PORT}?" "n"; then OPEN_FIREWALL=1; fi if ask_yes_no "按检测到的防火墙放行端口 ${PORT}?" "n"; then OPEN_FIREWALL=1; fi
if ask_yes_no "设为开机自启(systemctl enable ${SERVICE_NAME})?" "y"; then AUTOSTART=1; else AUTOSTART=0; fi
} }
# ---------------------------------------------------------------- 用户与目录 # ---------------------------------------------------------------- 用户与目录
@@ -901,8 +909,12 @@ EOF
fi fi
chmod 644 "/etc/systemd/system/${SERVICE_NAME}.service" chmod 644 "/etc/systemd/system/${SERVICE_NAME}.service"
systemctl daemon-reload systemctl daemon-reload
if [ "${AUTOSTART}" = "1" ]; then
systemctl enable "$SERVICE_NAME" >/dev/null systemctl enable "$SERVICE_NAME" >/dev/null
log "已安装 /etc/systemd/system/${SERVICE_NAME}.service 并设为开机自启" log "已安装 /etc/systemd/system/${SERVICE_NAME}.service 并设为开机自启"
else
log "已安装 /etc/systemd/system/${SERVICE_NAME}.service(**未**设为开机自启;需要时执行 systemctl enable ${SERVICE_NAME})"
fi
} }
install_aux_files() { install_aux_files() {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "classhelper-server-runtime", "name": "classhelper-server-runtime",
"version": "1.2.0", "version": "1.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)", "description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
+33 -2
View File
@@ -22,6 +22,12 @@ description: ClassHelper后端全部接口一览:方法、路径、权限与
| GET | `/auth/me` | 登录 | 当前用户(学生附带班级 / 年级) | | GET | `/auth/me` | 登录 | 当前用户(学生附带班级 / 年级) |
| PATCH | `/auth/password` | 登录 | 修改自己的密码(班级会话 → 班级密码) | | PATCH | `/auth/password` | 登录 | 修改自己的密码(班级会话 → 班级密码) |
| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) | | POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) |
| GET | `/permissions/me` | 登录 | 当前账号的生效权限(**无权限键**;班级端会话返回空集) |
| GET | `/permissions/catalog` | `permission:manage` | 权限键目录(权限页整棵勾选树的唯一来源) |
| GET / POST | `/permissions/groups` | `permission:manage` | 权限组列表 / 新建 |
| PATCH / DELETE | `/permissions/groups/:id` | `permission:manage` | 改名 / 改描述 / 整体替换权限键;内置组不可删 |
| GET | `/permissions/users` | `permission:manage` | 成员列表(所属权限组 + 生效权限数量) |
| PUT | `/permissions/users/:id/groups` | `permission:manage` | 整体替换某账号的权限组(空数组 = 回落角色默认模板) |
| GET | `/classes` | 登录 | 班级列表(按权限收敛) | | GET | `/classes` | 登录 | 班级列表(按权限收敛) |
| GET | `/classes/:id` | 班级可见 | 班级详情(学生 / 课程 / 班主任 / 各科任课老师) | | GET | `/classes/:id` | 班级可见 | 班级详情(学生 / 课程 / 班主任 / 各科任课老师) |
| POST | `/classes` | 管理员 | 创建班级(自动生成班级码,可指定班主任) | | POST | `/classes` | 管理员 | 创建班级(自动生成班级码,可指定班主任) |
@@ -46,11 +52,15 @@ description: ClassHelper后端全部接口一览:方法、路径、权限与
| PATCH / DELETE | `/homeworks/:id` | 教师 / 管理员 | 修改 / 删除(广播 `homework:new` / `homework:updated`) | | PATCH / DELETE | `/homeworks/:id` | 教师 / 管理员 | 修改 / 删除(广播 `homework:new` / `homework:updated`) |
| PATCH | `/homeworks/:id/status` | 登录 | 标记完成 / 取消(广播 `homework:status`) | | PATCH | `/homeworks/:id/status` | 登录 | 标记完成 / 取消(广播 `homework:status`) |
| GET / PATCH | `/homeworks/:id/submissions` | 登录 | 未交名单:读名单 / 回写全班完成状态 | | GET / PATCH | `/homeworks/:id/submissions` | 登录 | 未交名单:读名单 / 回写全班完成状态 |
| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) | | POST | `/homeworks/bulk` | `homework:write` | 一次给多个班级布置同一份作业(`subjectName` 跨班按名匹配科目;逐班结果) |
| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态;另支持 `date` / `from` / `to` / `createdBy`) |
| GET | `/notifications/unread-count` | 登录 | 未读数(红点) | | GET | `/notifications/unread-count` | 登录 | 未读数(红点) |
| POST | `/notifications` | 教师 / 管理员 | 发布通知(广播 `notification:new`);上课时段发紧急通知需 `confirmDuringClass` | | POST | `/notifications` | 教师 / 管理员 | 发布通知(广播 `notification:new`);上课时段发紧急通知需 `confirmDuringClass` |
| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读(班级设备会写全班) | | POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读(班级设备会写全班) |
| DELETE | `/notifications/:id` | 教师 / 管理员 | 删除通知 | | DELETE | `/notifications/:id` | 教师 / 管理员 | 删除通知 |
| GET | `/notifications/days?classId=&from=&to=&createdBy=` | 登录 | 通知按天归档(日期 + 条数,日期取 `assignDate`) |
| GET | `/notifications/creators?classId=&from=&to=&date=` | 登录 | 通知按发布人归档 |
| POST | `/notifications/bulk` | `notification:publish` | 一次给多个班级发同一条通知(逐班结果,**单班失败不影响整批**) |
| POST | `/calls` | 教师 / 管理员 | **叫人**(`urgent` 决定 URGENT / HIGH;广播 `notification:new` 与 `call:new`) | | POST | `/calls` | 教师 / 管理员 | **叫人**(`urgent` 决定 URGENT / HIGH;广播 `notification:new` 与 `call:new`) |
| GET | `/grades/my` | 登录 | 个人成绩(班级会话 → 全班总览) | | GET | `/grades/my` | 登录 | 个人成绩(班级会话 → 全班总览) |
| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师 / 管理员 | 班级成绩 | | GET | `/grades?classId=&courseId=&userId=&examName=` | 教师 / 管理员 | 班级成绩 |
@@ -72,7 +82,7 @@ description: ClassHelper后端全部接口一览:方法、路径、权限与
| GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) | | GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) |
| POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) | | POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) |
| POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) | | POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) |
| GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 16 张表行数 / 备份列表 / 定时配置) | | GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 23 张表行数 / 备份列表 / 定时配置) |
| POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) | | POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) |
| GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) | | GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) |
| POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 | | POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 |
@@ -85,6 +95,23 @@ description: ClassHelper后端全部接口一览:方法、路径、权限与
| POST | `/integrations/classisland/ack` | **设备令牌** | 插件确认提醒已弹出(确认后不再补发) | | POST | `/integrations/classisland/ack` | **设备令牌** | 插件确认提醒已弹出(确认后不再补发) |
| GET | `/integrations/classisland/class-plan` | **设备令牌** | 插件拉取本班课表(开启镜像时)用于写回 ClassIsland | | GET | `/integrations/classisland/class-plan` | **设备令牌** | 插件拉取本班课表(开启镜像时)用于写回 ClassIsland |
| GET | `/update/check` | 登录 | 更新检查(服务端代查 GitHub 最新 Release;`?force=1` 绕过缓存仅管理员生效) | | GET | `/update/check` | 登录 | 更新检查(服务端代查 GitHub 最新 Release;`?force=1` 绕过缓存仅管理员生效) |
| POST | `/clients/heartbeat` | 登录 | 客户端每 30 秒上报(**无权限键**);返回更新目标 + 安装包信息 + 一条待执行命令 |
| POST | `/clients/commands/:id/ack` | 登录 | 命令执行回执(**无权限键**,靠 body 的 `deviceKey` 校验归属,幂等) |
| POST | `/clients/commands` | `remote:control` | 下发远控命令(关机 / 重启 / 注销 / 锁屏;自动撤销该机更早的未回执命令) |
| GET | `/clients/devices?classId=` | `client:manage` | 客户端设备列表(版本 / 在线 / 待执行命令) |
| POST | `/clients/updates` | `client:update-push` | 统一下发客户端更新(按设备 / 班级 / 全部) |
| DELETE | `/clients/devices/:id` | `client:manage` | 移除设备记录(换机 / 旧机器清理) |
| GET | `/update/client/releases` | `client:manage` | 服务端已缓存了哪些客户端安装包 |
| POST | `/update/client/fetch` | `client:update-push` | 服务端代教室机器从 GitHub 下载安装包并登记 sha256 + size |
| POST | `/update/client/releases` | `client:update-push` | 手工登记已放进磁盘的安装包(内网 / U 盘分发兜底) |
| DELETE | `/update/client/releases/:version` | `client:update-push` | 删除缓存的安装包(元数据 + 磁盘文件) |
| GET | `/update/client/download/:version` | 登录 | 下载安装包(**故意不挂权限键**,班级端要能下;`res.download`) |
| GET / PUT | `/ai/settings` | `ai:manage` | 大模型设置(API Key 加密存储、**只进不出**,只回 `hasApiKey` / `keyHint`) |
| POST | `/ai/interpret` | `ai:use` | 一句话解析(**只解析不落库**);回 `action` / `params` / `preview` / `confidence` |
| GET | `/ai/audit?limit=` | `ai:manage` | 助手使用记录(每次解析写一条 `AiAudit`) |
| GET / PUT | `/system/settings` | `system:manage` | 系统设置(`developerMode`:开启后 500 响应带 `error.stack`) |
| GET / PUT | `/system/autostart` | `system:manage` | 开机自启状态 / 开关(Windows 注册表 Run 项,Linux systemd 单元) |
| GET | `/system/diagnostics` | `system:manage` | 运行诊断(版本 / Node / 运行时长 / 数据目录 / 在线客户端数 / 已加载模块) |
| GET | `/health` | 公开 | 健康检查(含版本号与已挂载模块) | | GET | `/health` | 公开 | 健康检查(含版本号与已挂载模块) |
另有三个探针(不带 `/api` 前缀):`/healthz`(存活)、`/readyz`(就绪)、以及 `/api/health`(详情)。 另有三个探针(不带 `/api` 前缀):`/healthz`(存活)、`/readyz`(就绪)、以及 `/api/health`(详情)。
@@ -99,6 +126,10 @@ description: ClassHelper后端全部接口一览:方法、路径、权限与
| **角色判定在密码校验之前** | 否则"密码错 / 密码对但账号停用"的差异会把学生账号密码变成可离线验证的预言机 | | **角色判定在密码校验之前** | 否则"密码错 / 密码对但账号停用"的差异会把学生账号密码变成可离线验证的预言机 |
| **会渲染成链接的字段要校验 scheme** | `Homework.attachmentUrl` 只允许 `http(s)://` 或站内相对路径 | | **会渲染成链接的字段要校验 scheme** | `Homework.attachmentUrl` 只允许 `http(s)://` 或站内相对路径 |
| **表格导入 8MB ↔ 请求体 12MB** | 文件以 base64 放进 JSON(约 4/3 倍),超限映射成 413 `IMPORT_TOO_LARGE` | | **表格导入 8MB ↔ 请求体 12MB** | 文件以 base64 放进 JSON(约 4/3 倍),超限映射成 413 `IMPORT_TOO_LARGE` |
| **三个接口故意不挂权限键** | `/permissions/me`、`/clients/heartbeat`、`/update/client/download/:version` —— 前两者由班级端(`CLASS_DEVICE`,权限集为空)调用,下载接口要能装包;保护只有"已登录 + 限流" |
| **查询参数校验失败是 422** | `middleware/validate.ts` 的 zod 解析错误由错误中间件映射成 422 `VALIDATION_ERROR`(不是 400) |
| **批量发布逐班判定、不整批回滚** | `/notifications/bulk` 与 `/homeworks/bulk` 的单班失败(403 越权 / `URGENT_DURING_CLASS`)不影响其它班,成功班照常落库并实时推送 |
| **`/ai/interpret` 只解析不落库** | 助手只回结构化参数与中文预览;落库由前端确认后调既有端点 —— 权限与上课时段保护都在那条路径上 |
## 相关 ## 相关
+12 -5
View File
@@ -37,7 +37,7 @@ class-helper/
│ ├── generate-icons.mjs # 启动 Electron 跑 icons/render.cjs(= pnpm icons) │ ├── generate-icons.mjs # 启动 Electron 跑 icons/render.cjs(= pnpm icons)
│ ├── icons/render.cjs # 把 classhelper.png 重采样成各端图标(CommonJS) │ ├── icons/render.cjs # 把 classhelper.png 重采样成各端图标(CommonJS)
│ ├── version.mjs # 版本号单一来源的读写与一致性校验(= pnpm version:*) │ ├── version.mjs # 版本号单一来源的读写与一致性校验(= pnpm version:*)
│ ├── dist-server.mjs # 服务端 + Web 管理端打包(免安装目录 + NSIS 安装程序) │ ├── dist-server.mjs # 服务端 + Web 管理端打包(组装运行时 → NSIS 安装程序)
│ ├── verify-packaged.mjs # 对打包后 / 已安装的客户端 EXE 复跑冒烟 │ ├── verify-packaged.mjs # 对打包后 / 已安装的客户端 EXE 复跑冒烟
│ ├── lib/ # electron-env / node-runtime / smoke-class │ ├── lib/ # electron-env / node-runtime / smoke-class
│ ├── nsis/server-installer.nsi# 安装程序脚本模板(版本号由 dist-server 注入) │ ├── nsis/server-installer.nsi# 安装程序脚本模板(版本号由 dist-server 注入)
@@ -66,7 +66,7 @@ class-helper/
├── shared/ # 三端共享:类型契约、常量、权限矩阵、动效令牌、工具函数 ├── shared/ # 三端共享:类型契约、常量、权限矩阵、动效令牌、工具函数
│ └── src/{types,constants,permissions,motion,island-layout,utils,index}.ts │ └── src/{types,constants,permissions,motion,island-layout,utils,index}.ts
├── server/ # 后端服务 ├── server/ # 后端服务
│ ├── prisma/schema.prisma # 数据模型(14 个 model,只有一份,切库改 provider) │ ├── prisma/schema.prisma # 数据模型(24 个 model,只有一份,切库改 provider)
│ ├── prisma/migrations/ # 迁移历史(10 个) │ ├── prisma/migrations/ # 迁移历史(10 个)
│ ├── prisma/seed.ts # 种子数据(会清空业务表后重建演示数据) │ ├── prisma/seed.ts # 种子数据(会清空业务表后重建演示数据)
│ ├── scripts/ # verify-e2e.mjs / verify-classisland.mjs / apply-column-migrations.cjs │ ├── scripts/ # verify-e2e.mjs / verify-classisland.mjs / apply-column-migrations.cjs
@@ -105,9 +105,10 @@ class-helper/
后端每个功能是一个独立目录,统一契约: 后端每个功能是一个独立目录,统一契约:
```ts ```ts
// packages/server/src/modules/registry.ts(当前 15 个模块) // packages/server/src/modules/registry.ts(当前 21 个模块)
export const apiModules: ApiModule[] = [ export const apiModules: ApiModule[] = [
authModule, authModule,
aiModule, // 大模型助手(只解析不落库,见 docs/dev/api.md)
classesModule, classesModule,
coursesModule, coursesModule,
schedulesModule, schedulesModule,
@@ -119,9 +120,14 @@ export const apiModules: ApiModule[] = [
gradesModule, gradesModule,
studentsModule, studentsModule,
teachersModule, teachersModule,
archivesModule,
termModule,
dashboardModule, dashboardModule,
databaseModule, // 数据库管理(仅管理员) databaseModule, // 数据库管理(仅管理员)
updateModule, // 更新检查(服务端唯一出网请求) updateModule, // 更新检查 + 客户端安装包缓存与统一下发
permissionsModule, // 权限组管理
clientsModule, // 客户端设备心跳 / 远控命令
systemModule, // 开发者模式 / 开机自启 / 运行诊断
].filter((module) => module.enabled !== false); ].filter((module) => module.enabled !== false);
``` ```
@@ -138,7 +144,8 @@ export const apiModules: ApiModule[] = [
| 约定 | 为什么 | | 约定 | 为什么 |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------ | | ---------------------------- | ------------------------------------------------------------------------------------------------------------ |
| **统一响应与校验** | 用 `lib/http.ts` 的 `sendOk/sendCreated`,用 `middleware/validate.ts` 挂 zod,错误交给 `middleware/error.ts` | | **统一响应与校验** | 用 `lib/http.ts` 的 `sendOk/sendCreated`,用 `middleware/validate.ts` 挂 zod,错误交给 `middleware/error.ts` |
| **权限判在服务层** | 路由只做角色过滤,班级维度的判断用 `lib/access.ts` 断言 —— 前端隐藏 ≠ 权限 | | **功能级准入看权限键** | 路由挂 `requirePermission('…')` / `requireAnyPermission(…)`,角色只是默认模板(`ROLE_DEFAULT_PERMISSIONS`)—— 见 `docs/dev/permissions.md` |
| **权限判在服务层** | 权限组只管功能级;班级维度与科目维度仍用 `lib/access.ts` 的断言(`assertCanManageSubjectContent` 等),**权限组不可覆盖** —— 前端隐藏 ≠ 权限 |
| **周一为新一周的第一天** | 周次口径唯一:`resolveCurrentWeek(TERM_START_DATE, now, MAX_TERM_WEEK)` | | **周一为新一周的第一天** | 周次口径唯一:`resolveCurrentWeek(TERM_START_DATE, now, MAX_TERM_WEEK)` |
| **作业按 `assignDate` 归类** | 用 `createdAt`(UTC)会在晚上把作业算到第二天 | | **作业按 `assignDate` 归类** | 用 `createdAt`(UTC)会在晚上把作业算到第二天 |
| **新增实时事件要改三处** | 服务端 bus/socket、Web 端订阅、客户端订阅(灵动岛也依赖) | | **新增实时事件要改三处** | 服务端 bus/socket、Web 端订阅、客户端订阅(灵动岛也依赖) |
+34 -9
View File
@@ -1,6 +1,6 @@
--- ---
title: 数据模型 title: 数据模型
description: 16 张表的职责、跨库通用约定、迁移历史与启动时自动迁移。 description: 24 张表的职责、跨库通用约定、迁移历史与启动时自动迁移。
--- ---
# 数据模型 # 数据模型
@@ -30,11 +30,11 @@ description: 16 张表的职责、跨库通用约定、迁移历史与启动时
同一位教师可以在同一个班同时是班主任和某科科任老师:两处指向同一个 `User`,不重复建号, 同一位教师可以在同一个班同时是班主任和某科科任老师:两处指向同一个 `User`,不重复建号,
权限在 `@classhelper/shared` 的 `resolveClassRole()` 里合并。 权限在 `@classhelper/shared` 的 `resolveClassRole()` 里合并。
## 16 张表 ## 24 张表
| 表 | 职责 | 值得注意的字段 | | 表 | 职责 | 值得注意的字段 |
| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- | | ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| `User` | **纯账号表**:管理员 / 教师 / ClassHelper 班级端 | `username`(教师即**工号**,班级端即班级码)、`role`、`phone`;**学生不在这里** | | `User` | **纯账号表**:管理员 / 教师 / ClassHelper 班级端 | `username`(教师即**工号**,班级端即班级码)、`role`、`phone`、`careMode`(关怀模式偏好,目前只由本机界面读写);**学生不在这里** |
| `Student` | 学生名单记录(**没有账号**) | `studentNo`(学号,唯一,唯一标识与查询键)、`classId`、`status`、`archivedYearId` | | `Student` | 学生名单记录(**没有账号**) | `studentNo`(学号,唯一,唯一标识与查询键)、`classId`、`status`、`archivedYearId` |
| `StudentClassTransfer` | 调班 / 转出历史 | 班级 id 与班级名**都存快照且不建外键**(班级删了历史仍要能读)、`mode`(single|batch|transfer-out) | | `StudentClassTransfer` | 调班 / 转出历史 | 班级 id 与班级名**都存快照且不建外键**(班级删了历史仍要能读)、`mode`(single|batch|transfer-out) |
| `ArchivedYear` | 毕业归档的届别档案 | `enrollmentYear`(届别,唯一)、`graduationYear`、归档时的统计快照 | | `ArchivedYear` | 毕业归档的届别档案 | `enrollmentYear`(届别,唯一)、`graduationYear`、归档时的统计快照 |
@@ -44,12 +44,20 @@ description: 16 张表的职责、跨库通用约定、迁移历史与启动时
| `Schedule` | 课表条目 | `weekStart` / `weekEnd` / `weekParity`(单双周)/ `source`(manual|classisland) | | `Schedule` | 课表条目 | `weekStart` / `weekEnd` / `weekParity`(单双周)/ `source`(manual|classisland) |
| `Homework` | 作业 | `assignDate`(所属日期)、`attachmentUrl`;`dueAt` 是已下线的遗留列 | | `Homework` | 作业 | `assignDate`(所属日期)、`attachmentUrl`;`dueAt` 是已下线的遗留列 |
| `HomeworkStatus` | 学生作业完成状态 | `@@unique([homeworkId, studentId])` | | `HomeworkStatus` | 学生作业完成状态 | `@@unique([homeworkId, studentId])` |
| `Notification` | 通知 | `priority` | | `Notification` | 通知 | `priority`、`assignDate`(所属本地日期,禁止 UTC 切片);`@@index([classId, assignDate])` 供按天归档 |
| `NotificationRead` | 已读记录 | `@@unique([notificationId, studentId])` | | `NotificationRead` | 已读记录 | `@@unique([notificationId, studentId])` |
| `TimeLayout` | 节次时间配置(ClassIsland 同款 JSON 导入得到) | `items` 是 JSON 字符串:`[{ index, name, startTime, endTime, type, skipped }]` | | `TimeLayout` | 节次时间配置(ClassIsland 同款 JSON 导入得到) | `items` 是 JSON 字符串:`[{ index, name, startTime, endTime, type, skipped }]` |
| `Grade` | 成绩 | `studentId`、`score` / `totalScore`、`levelType`(percent|letter|custom)与 `level` | | `Grade` | 成绩 | `studentId`、`score` / `totalScore`、`levelType`(percent|letter|custom)与 `level` |
| `IntegrationDevice` | ClassHelper 班级端 / ClassIsland 联动设备 | `tokenHash`(只存 sha256)、`lastHeartbeatAt`(在线判定)、`lastSeenAt`(最后一次带状态的上报)、三个方向开关 | | `IntegrationDevice` | ClassHelper 班级端 / ClassIsland 联动设备 | `tokenHash`(只存 sha256)、`lastHeartbeatAt`(在线判定)、`lastSeenAt`(最后一次带状态的上报)、三个方向开关 |
| `ClassIslandPush` | 下发给 ClassIsland 的提醒 | `kind`(notification|call)、`ackedAt`、`expiresAt` | | `ClassIslandPush` | 下发给 ClassIsland 的提醒 | `kind`(notification|call)、`ackedAt`、`expiresAt` |
| `PermissionGroup` | 权限组(角色之上的**默认模板**) | `name`(唯一)、`builtin`(内置组不可删);生效集合见 [角色与权限](permissions.md) |
| `PermissionGroupItem` | 权限组里的一个权限键(一行一个键) | `@@unique([groupId, key])`;键取值必须是 shared 的 `PERMISSION_KEYS` 之一 |
| `UserPermissionGroup` | 账号 ←→ 权限组 的分配关系(可属多个组) | `@@unique([userId, groupId])`;**没有任何行 = 回落角色默认模板** |
| `AppSetting` | 全局键值设置(大模型配置 / 开发者模式) | `key` 即主键、`value` 存字符串(复杂结构存 JSON);密钥类值做 AES-256-GCM 加密后存这里(**只进不出**) |
| `ClientDevice` | ClassHelper 客户端设备(教室 / 学生机) | `deviceKey`(唯一,客户端生成)、`classId`、`clientVersion`、`updateTargetVersion`、`lastHeartbeatAt`(**在线判定看它**) |
| `ClientCommand` | 下发给客户端的远控指令 | `kind`(shutdown|restart|logoff|lock)、`expiresAt`(10 分钟)、`ackedAt` / `result`、`cancelledAt` |
| `ClientRelease` | 服务端缓存的客户端安装包 | `version`(唯一,白名单校验防路径穿越)、`fileName`、`sha256`、`size`;文件在 `data/client-releases/` |
| `AiAudit` | 大模型指令审计(意图与结果,不记密钥) | `userId`、`prompt`、`intent`、`status`(pending|confirmed|rejected|failed)、`rawReply` |
### 几个容易忽略的设计 ### 几个容易忽略的设计
@@ -74,17 +82,26 @@ description: 16 张表的职责、跨库通用约定、迁移历史与启动时
**`Homework.dueAt`** —— 截止时间功能已下线,UI 与接口都不再读写它。 **`Homework.dueAt`** —— 截止时间功能已下线,UI 与接口都不再读写它。
保留列是为了**不对已安装的库做破坏性迁移**;新库里它恒为 `null`。 保留列是为了**不对已安装的库做破坏性迁移**;新库里它恒为 `null`。
**`Notification.assignDate`** —— 与 `Homework.assignDate` 同口径:**本地日期**(`YYYY-MM-DD`),
创建时用 `dayKeyLocal()` 写入,按天 / 区间归档都读它。**不要用 `createdAt` 切 UTC 当"哪一天"** ——
晚上发的通知会被算到第二天。`@@index([classId, assignDate])` 是归档查询的支撑。
**`User.careMode`** —— 关怀模式的账号级偏好列(放大正文与控件)。**目前服务端没有任何接口读写它**:
Web 端存在 `localStorage`(`classhelper.admin.careMode` + `careScale`),客户端存在本机 `config.json`。
列先建好是为了将来跨设备同步偏好时不必再迁移;别把它当成"已有服务端开关"。
**`TermWeek.classId` 用空串而不是 null 表示"全校默认"** —— SQLite 与 MySQL 的唯一约束都把 **`TermWeek.classId` 用空串而不是 null 表示"全校默认"** —— SQLite 与 MySQL 的唯一约束都把
NULL 视作互不相同,`@@unique([classId, weekNumber])` 用 null 就形同虚设。 NULL 视作互不相同,`@@unique([classId, weekNumber])` 用 null 就形同虚设。
## 迁移历史(16 个) ## 迁移历史(17 个)
``` ```
init → add_time_layout → add_class_account → add_schedule_week_parity → init → add_time_layout → add_class_account → add_schedule_week_parity →
add_class_term_weeks → add_classisland_integration → add_class_notification_channel → add_class_term_weeks → add_classisland_integration → add_class_notification_channel →
add_homework_assign_date → add_push_kind → add_schedule_source → add_homework_assign_date → add_push_kind → add_schedule_source →
add_student_table → add_student_grade_query → add_grade_level → add_student_table → add_student_grade_query → add_grade_level →
add_classhelper_heartbeat → add_archives → add_term_weeks add_classhelper_heartbeat → add_archives → add_term_weeks →
add_permission_groups_and_clients
``` ```
`add_student_table` 是一次真正的表重构(不是加列):新建 `Student` / `StudentClassTransfer`、 `add_student_table` 是一次真正的表重构(不是加列):新建 `Student` / `StudentClassTransfer`、
@@ -92,6 +109,12 @@ add_classhelper_heartbeat → add_archives → add_term_weeks
`NotificationRead` 的外键从 `userId` 换到 `studentId`、删掉 `Enrollment` 与 `ClassTeacher`。 `NotificationRead` 的外键从 `userId` 换到 `studentId`、删掉 `Enrollment` 与 `ClassTeacher`。
因为学生行的 id 保持不变,三张个人数据表的 `userId` 可以直接当 `studentId` 用,历史一条不丢。 因为学生行的 id 保持不变,三张个人数据表的 `userId` 可以直接当 `studentId` 用,历史一条不丢。
`add_permission_groups_and_clients` 是纯加表 / 加列:新建 `PermissionGroup` /
`PermissionGroupItem` / `UserPermissionGroup`(权限组)、`AppSetting`(全局键值设置)、
`ClientDevice` / `ClientCommand`(客户端设备与远控)、`ClientRelease`(安装包缓存)、
`AiAudit`(大模型审计),并给 `Notification` 加 `assignDate` + `[classId, assignDate]` 索引、
给 `User` 加 `careMode`。没有数据搬运,旧库直接补跑即可。
### 启动时自动迁移 ### 启动时自动迁移
`src/lib/db-bootstrap.ts` 用一张账本表 `_ch_migrations` 记录已执行的迁移目录名: `src/lib/db-bootstrap.ts` 用一张账本表 `_ch_migrations` 记录已执行的迁移目录名:
@@ -107,8 +130,10 @@ add_classhelper_heartbeat → add_archives → add_term_weeks
## 备份格式就是这份模型的拓扑序导出 ## 备份格式就是这份模型的拓扑序导出
「数据库管理」里的备份 / 导出导入 / 跨库迁移共用一种 JSON 快照:16 张表按拓扑序导出 「数据库管理」里的备份 / 导出导入 / 跨库迁移共用一种 JSON 快照:**23 张表按拓扑序导出**
(见 `lib/snapshot.ts` 的 `SNAPSHOT_TABLES`),恢复时**临时关闭外键检查** —— (见 `lib/snapshot.ts` 的 `SNAPSHOT_TABLES`,现在共 23 项,顺序为
`User` → `PermissionGroup` → `PermissionGroupItem` → `UserPermissionGroup` → `AppSetting` →
`ArchivedYear` → `Class` → `Student` → … → `AiAudit`),恢复时**临时关闭外键检查** ——
因为 `Class.teacherId → User` 与 `Student.classId → Class` 是一条链、 因为 `Class.teacherId → User` 与 `Student.classId → Class` 是一条链、
`Class/Student.archivedYearId → ArchivedYear` 又是一条,任何排序都会被其中一侧卡死。 `Class/Student.archivedYearId → ArchivedYear` 又是一条,任何排序都会被其中一侧卡死。
见[数据库管理](../management/database.md)。 见[数据库管理](../management/database.md)。
@@ -127,4 +152,4 @@ add_classhelper_heartbeat → add_archives → add_term_weeks
再回到 `packages/server/scripts/apply-column-migrations.cjs` 补上可 `ALTER TABLE` 的简单列。 再回到 `packages/server/scripts/apply-column-migrations.cjs` 补上可 `ALTER TABLE` 的简单列。
模型总数与迁移个数会随版本增长,以 `schema.prisma` 与 `prisma/migrations/` 的实际内容为准; 模型总数与迁移个数会随版本增长,以 `schema.prisma` 与 `prisma/migrations/` 的实际内容为准;
本文里的数字标注的是当前版本(1.2.0)的状态。 本文里的数字标注的是**当前代码库**的状态。
+1 -1
View File
@@ -38,7 +38,7 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
| [架构总览](architecture.md) | 目录结构、技术栈、模块化设计、几条贯穿全局的约定 | | [架构总览](architecture.md) | 目录结构、技术栈、模块化设计、几条贯穿全局的约定 |
| [REST API](api.md) | 全部接口一览(方法 / 路径 / 权限) | | [REST API](api.md) | 全部接口一览(方法 / 路径 / 权限) |
| [实时事件(Socket.IO)](realtime.md) | 事件清单、房间规则、新增事件要改哪三处 | | [实时事件(Socket.IO)](realtime.md) | 事件清单、房间规则、新增事件要改哪三处 |
| [数据模型](data-model.md) | 16 张表、迁移历史、跨库约定 | | [数据模型](data-model.md) | 24 张表、迁移历史、跨库约定 |
| [角色与权限](permissions.md) | 权限矩阵与实现位置 | | [角色与权限](permissions.md) | 权限矩阵与实现位置 |
| [动效层与主题](motion.md) | beUI 令牌、CSS `linear()` 弹簧、深色主题 | | [动效层与主题](motion.md) | beUI 令牌、CSS `linear()` 弹簧、深色主题 |
| [ClassIsland 插件](classisland-plugin.md) | 插件契约、镜像为什么要带 `startTime`、部署与调试 | | [ClassIsland 插件](classisland-plugin.md) | 插件契约、镜像为什么要带 `startTime`、部署与调试 |
+49 -2
View File
@@ -67,9 +67,50 @@ description: 权限矩阵、科目维度的收口规则,以及"前端隐藏
实现在 `lib/access.ts` 的 **`assertCanManageSubjectContent(user, classId, courseId, action, { allowClassDevice })`**, 实现在 `lib/access.ts` 的 **`assertCanManageSubjectContent(user, classId, courseId, action, { allowClassDevice })`**,
作业 / 成绩 / 叫人三处写入统一走它。 作业 / 成绩 / 叫人三处写入统一走它。
## 权限键与权限组(2026-10 起:权限组驱动,角色只是默认模板)
从权限组重构开始,**功能级准入不再看角色,而看权限键**:键目录同样是
`packages/shared/src/permissions.ts` 的 `PERMISSION_KEYS`(39 个,形如 `domain:action`,
中文名在 `PERMISSION_LABELS`、分组在 `PERMISSION_CATEGORIES`)。路由上挂的是
```ts
requirePermission('notification:publish') // 需要全部列出的键
requireAnyPermission('grade:write', 'grade:import') // 有任意一个即可
```
**生效规则(四句话)**:
| 账号 | 有效权限 |
| --------------------------------------- | -------------------------------------------------------------- |
| `ADMIN` | 全集;其中 `permission:manage` / `system:manage` **不可被剥夺** |
| `CLASS_DEVICE`(班级端) | 空集,**不受任何分组影响**(它的能力来自班级维度断言) |
| 被分配了权限分组 | 分组键的**并集**(可以比角色默认更少 —— 这就是"能收权") |
| 没有被分配任何分组 | 回落到角色默认模板 `defaultPermissionsForRole(role)` |
默认模板逐条镜像重构前的 `requireRole`,所以"不动分组"= 行为与以前完全一致。
实现见 `packages/server/src/lib/permissions.ts` 的 `loadEffectivePermissions()`:它**每个请求实时查库**
(不缓存),因此管理员改完分组**立刻生效**,不需要等令牌过期 —— 与下面「鉴权中间件会回查数据库」是同一个理由。
**管理接口**(`packages/server/src/modules/permissions/`):
| 方法与路径 | 权限要求 | 说明 |
| --------------------------------- | -------------------- | -------------------------------------------------------- |
| `GET /api/permissions/me` | 登录即可 | 当前账号的有效权限与来源(前端据此显示/隐藏入口) |
| `GET /api/permissions/catalog` | `permission:manage` | 全部权限键(含中文名、分类、默认角色、不可移除标记) |
| `GET/POST /api/permissions/groups`、`PATCH/DELETE /groups/:id` | `permission:manage` | 分组 CRUD;内置分组(`builtin=true`)可改不可删 |
| `GET /api/permissions/users`、`PUT /api/permissions/users/:id/groups` | `permission:manage` | 成员列表与「整组替换」式分配;拒绝给 ADMIN / CLASS_DEVICE 分配 |
**一条不能松的边界**:权限组只管**功能/路由级**准入;**班级维度的科目规则不可被权限组覆盖**。
换句话说,给某人授了 `homework:write` 只代表"他能进布置作业这条路由",
**具体到哪个班、哪个科目**仍然由 `assertCanManageSubjectContent` 判(下一节)。
否则「班主任能布置别人科目的作业」这个老 bug 会从权限组这条新路重新长出来。
## 实现要点 ## 实现要点
**路由级只做角色过滤**:`requireRole('ADMIN', 'TEACHER')`。 **路由级只做权限过滤**:`requirePermission('…')`(需要列出的键全有)或
`requireAnyPermission('…')`(任意一个即可)。改造前这里是 `requireRole('ADMIN','TEACHER')`,
按键目录里的 `PERMISSION_DEFAULT_ROLES` 逐条映射过来,因此**默认行为没有变化**。
403 的文案会直接点名缺哪个键:「当前账号缺少「X」权限,请联系管理员在「权限组」里分配」。
**班级维度的判断在服务层**:`src/lib/access.ts` 提供 **班级维度的判断在服务层**:`src/lib/access.ts` 提供
@@ -93,10 +134,16 @@ assertCanManageGrades / assertCanManageSubjectContent
前端的隐藏只是"别让人看见入口": 前端的隐藏只是"别让人看见入口":
- 教师登录时侧边栏不显示「班级管理 / 学生管理 / 教师管理 / 毕业归档 / 学期周次 / 数据库管理」; - 教师登录时侧边栏不显示「班级管理 / 学生管理 / 教师管理 / 毕业归档 / 学期周次 / 数据库管理 / 权限组 / 客户端设备」;
- 课表页的增删改按钮只对「管理员或本班班主任」显示; - 课表页的增删改按钮只对「管理员或本班班主任」显示;
- 成绩页的录入 / 删除按钮对教师显示(科任也能用,**具体到某一条由后端按科目判定**)。 - 成绩页的录入 / 删除按钮对教师显示(科任也能用,**具体到某一条由后端按科目判定**)。
**门控怎么写的**:路由 `meta.roles` 做粗筛(角色),`meta.permission` 做细筛(权限键)——
`packages/web-admin/src/router/index.ts` 的 `beforeEach` 会 `await auth.ensurePermissions()`
再问 `auth.can(key)`(管理员短路为 true),不通过就 **fail-closed** 回仪表盘;
侧栏菜单项同理带 `permission` 字段(`AdminLayout.vue` 的 `visibleMenuItems`)。
`auth.ensurePermissions()` 读的就是 `GET /api/permissions/me`。
但这些页面**直接输网址也进不去**(路由 `meta.roles` 会把非管理员挡回仪表盘), 但这些页面**直接输网址也进不去**(路由 `meta.roles` 会把非管理员挡回仪表盘),
而且**所有写入请求服务端都会再断言一次**。绕过菜单、绕过前端,一样 403。 而且**所有写入请求服务端都会再断言一次**。绕过菜单、绕过前端,一样 403。
+6 -2
View File
@@ -95,11 +95,15 @@ pnpm build && pnpm verify:web
pnpm dist:all # = dist:server + dist:win + dist:classisland-plugin pnpm dist:all # = dist:server + dist:win + dist:classisland-plugin
``` ```
产物落在 `releases/<组件>/<版本>/`:`安装包/`、`免安装/`、`构建中间/`。 产物落在 `releases/<组件>/<版本>/`:`安装包/`(交付物)、`构建中间/`(可重新生成)。
**没有免安装目录** —— 组装/解包出来的那一份是中间物,打包脚本用完就删(见仓库根 `AGENTS.md` §4
「交付产物归集」)。
打完之后**对打包副本再验一次**(开发产物通过 ≠ 用户机器上的副本通过): 打完之后**对打包副本再验一次**(开发产物通过 ≠ 用户机器上的副本通过):
```bash ```bash
pnpm verify:packaged --exe "releases/client/<版本>/免安装/ClassHelper.exe" # 默认指向装好的客户端;推荐直接拿刚打出来的便携版单文件当被测副本
pnpm verify:packaged --exe "releases/client/<版本>/安装包/ClassHelper-<版本>-x64-portable.exe"
``` ```
> Linux 包**只能在 Linux 上构建**(依赖是平台相关的),所以不在这条命令里 —— > Linux 包**只能在 Linux 上构建**(依赖是平台相关的),所以不在这条命令里 ——
+1 -1
View File
@@ -20,7 +20,7 @@ description: 在教室机器上安装 ClassHelper桌面客户端,用班级码
要自己打: 要自己打:
```bash ```bash
pnpm dist:win # 安装包 + 便携版;只要免安装目录用 pnpm dist:dir pnpm dist:win # 安装包 + 便携版(两个都是成品,没有中间的解包目录)
``` ```
> **装不上 / 双击没反应 / 一闪就没**(报 `0x80000003`)通常不是产物坏了,而是交付物被打了 > **装不上 / 双击没反应 / 一闪就没**(报 `0x80000003`)通常不是产物坏了,而是交付物被打了
+5 -2
View File
@@ -31,9 +31,12 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已装依赖,跳过
产物: 产物:
| 路径 | 说明 | | 路径 | 说明 |
| --------------------------------------------------------------------- | ------------------------------------------- | | ----------------------------------------------------------------------- | -------------------------- |
| `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) | | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
| `releases/server/<版本>/免安装/` | 免安装目录,拷到任意 Windows x64 机器即可跑 |
> 交付产物只有安装程序。组装出来的那份运行时(内置 Node + 依赖 + 后端与 Web 产物,约 335 MB)
> 只是打安装包的中间物,落在 `.cache/server-staging/<版本>/` 下,打完脚本就删掉了 ——
> 想留一份可直接双击运行的副本,见本文末「不用安装程序的手动部署」。
包内自带 Node 运行时、后端产物、Web 管理端产物、生产依赖、迁移 SQL、随机 `JWT_SECRET` 的 `.env` 包内自带 Node 运行时、后端产物、Web 管理端产物、生产依赖、迁移 SQL、随机 `JWT_SECRET` 的 `.env`
与启停脚本。目标机器**不需要预先安装 Node.js**。 与启停脚本。目标机器**不需要预先安装 Node.js**。
+2 -2
View File
@@ -12,7 +12,7 @@ Web 管理端有一个「数据库管理」页(`/database`,**仅管理员可
| 能力 | 说明 | | 能力 | 说明 |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 连接状态检测 | 当前类型(SQLite / MySQL)、连通性与延迟、引擎版本、数据体积、16 张表的行数、连接串(MySQL 密码脱敏) | | 连接状态检测 | 当前类型(SQLite / MySQL)、连通性与延迟、引擎版本、数据体积、23 张表的行数、连接串(MySQL 密码脱敏) |
| 连接测试 | 对任意目标库试连(切换前的第一步) | | 连接测试 | 对任意目标库试连(切换前的第一步) |
| **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → 生成客户端 / 建表 → 子进程迁移全部数据 → 改写 `.env`」,异步任务带步骤进度,**失败自动回滚** | | **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → 生成客户端 / 建表 → 子进程迁移全部数据 → 改写 `.env`」,异步任务带步骤进度,**失败自动回滚** |
| 备份 / 恢复 | gzip JSON 快照存在 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 | | 备份 / 恢复 | gzip JSON 快照存在 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 |
@@ -23,7 +23,7 @@ Web 管理端有一个「数据库管理」页(`/database`,**仅管理员可
**备份、导入导出、跨库迁移共用同一种「JSON 快照」**: **备份、导入导出、跨库迁移共用同一种「JSON 快照」**:
- 16 张表按**拓扑序**导出; - 23 张表按**拓扑序**导出;
- 恢复时临时**关闭外键检查** —— 因为 `User.classId → Class` 与 `Class.teacherId → User` - 恢复时临时**关闭外键检查** —— 因为 `User.classId → Class` 与 `Class.teacherId → User`
是**互相引用**的,任何排序都会被其中一侧卡死; 是**互相引用**的,任何排序都会被其中一侧卡死;
- 分批 `createMany` 写入。 - 分批 `createMany` 写入。
+1 -1
View File
@@ -14,7 +14,7 @@ description: 部署形态怎么选、备份与恢复、换数据库、升级与
| **Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | NSIS 安装程序,内置 Node 运行时 | [生产部署指南](production.md) | | **Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | NSIS 安装程序,内置 Node 运行时 | [生产部署指南](production.md) |
| **Linux 原生 + systemd** | 学校已有 Linux 服务器 | 一键安装器 + `classhelper` 运维命令 | [Linux 部署与运维](linux-deploy.md) | | **Linux 原生 + systemd** | 学校已有 Linux 服务器 | 一键安装器 + `classhelper` 运维命令 | [Linux 部署与运维](linux-deploy.md) |
| **Docker** | 云服务器、跟别的服务共用一台机器 | `deploy/docker-compose*.yml` | [生产部署指南](production.md) | | **Docker** | 云服务器、跟别的服务共用一台机器 | `deploy/docker-compose*.yml` | [生产部署指南](production.md) |
| **手动部署** | 已有 Node 环境、需要自定义 | 免安装目录 | [生产部署指南](production.md) | | **手动部署** | 已有 Node 环境、需要自定义 | 仓库源码(`pnpm build` 后直接跑) | [生产部署指南](production.md) |
一句话的取舍: 一句话的取舍:
+19 -9
View File
@@ -7,7 +7,7 @@
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) | | **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 | | **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 |
| **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](https://github.com/LoveChengke/classhelper/blob/master/deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 本机无 Linux,实机验收见 [linux-deploy.md](linux-deploy.md) §10 | | **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](https://github.com/LoveChengke/classhelper/blob/master/deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 本机无 Linux,实机验收见 [linux-deploy.md](linux-deploy.md) §10 |
| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(163 项端到端) | | **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | 在仓库里构建后直接跑(见第四节) | ✅ 已实测(163 项端到端) |
--- ---
@@ -23,10 +23,13 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
产物: 产物:
| 路径 | 说明 | | 路径 | 说明 |
| ----------------------------------------------------------------------- | ----------------------------------------------- | | ----------------------------------------------------------------------- | -------------------------- |
| `releases/server/<版本>/免安装/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 |
| `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) | | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
> 组装出来的运行时(内置 Node + 生产依赖 + 后端与 Web 产物,约 335 MB)是打安装包的中间物,
> 落在 `.cache/server-staging/<版本>/`,脚本打完就删 —— 交付目录里只留安装程序。
> 需要在有 Node 的机器上手动跑,见第四节。
打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、 打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、
生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。 生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。
@@ -38,7 +41,8 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
- 静默安装:`"ClassHelper 服务端-1.2.0-x64-setup.exe" /S` - 静默安装:`"ClassHelper 服务端-1.2.0-x64-setup.exe" /S`
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载 - 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
- 桌面:`ClassHelper 服务端.lnk`(打开管理端) - 桌面:`ClassHelper 服务端.lnk`(打开管理端)
- 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`) - **开机自启动可选**:安装向导的组件页里勾选(默认勾选),装完之后还能在管理端「系统设置」里随时开关
(`GET/PUT /api/system/autostart`);勾选时写的是 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ClassHelperServer`
- 卸载信息注册到「应用和功能」 - 卸载信息注册到「应用和功能」
安装完成后服务会自动启动并完成首次初始化: 安装完成后服务会自动启动并完成首次初始化:
@@ -274,21 +278,27 @@ sudo bash /tmp/verify-linux.sh --full --package /tmp/classhelper-server-linux-x6
## 四、手动部署(形态 D) ## 四、手动部署(形态 D)
目标机已有 Node.js(≥ 20.19)时,不需要任何安装包,直接在仓库里构建后跑:
```bash ```bash
pnpm install pnpm install
pnpm build:shared pnpm build:shared
pnpm --filter @classhelper/server build pnpm --filter @classhelper/server build
pnpm --filter @classhelper/web-admin build pnpm --filter @classhelper/web-admin build
pnpm dist:server -- --reuse-deps # 得到 releases/server/<版本>/免安装/ 免安装目录
``` ```
把该目录拷贝到目标机,编辑 `.env`(端口、数据库、密钥、跨域),然后: 按目标机的实际情况写好 `packages/server/.env`(端口、数据库、密钥、跨域),然后:
```cmd ```bash
start.cmd :: Windows pnpm --filter @classhelper/server start # 或 node packages/server/dist/index.js
# 或直接:node server/dist/index.js (Linux/macOS 用系统 node,见上面的形态 C)
``` ```
> 想把这份构建结果整体拷到另一台机器,就自己带上这四样:
> `packages/server/dist`、`packages/server/node_modules`(含 `src/generated/prisma`)、
> `packages/web-admin/dist`(服务端按 `web/` 静态托管)、以及 `packages/shared/dist`。
> `pnpm dist:server` 组装的正是这套东西,只是它现在把结果当中间物用、打完即删 ——
> 需要留档时把 `.cache/server-staging/<版本>/win/` 在打包过程中拷出来即可。
数据库初始化二选一: 数据库初始化二选一:
- 让服务自愈:`.env` 中 `AUTO_MIGRATE=true`(SQLite 首次启动自动建表 + 建管理员) - 让服务自愈:`.env` 中 `AUTO_MIGRATE=true`(SQLite 首次启动自动建表 + 建管理员)
+1 -1
View File
@@ -98,7 +98,7 @@ docker compose up -d
### 手动 / 源码 ### 手动 / 源码
拉新代码或替换免安装目录 → 重启服务端。启动时 `db-bootstrap` 会用账本表 `_ch_migrations` 拉新代码(或覆盖安装新的服务端程序)→ 重启服务端。启动时 `db-bootstrap` 会用账本表 `_ch_migrations`
对比已执行的迁移,只补跑缺的那些: 对比已执行的迁移,只补跑缺的那些:
- 全新安装:依次执行全部迁移并创建初始管理员; - 全新安装:依次执行全部迁移并创建初始管理员;
+224 -45
View File
@@ -1,7 +1,7 @@
# ClassHelper · 完整参考 # ClassHelper · 完整参考
班级信息管理系统。一个 pnpm monorepo,一份代码产出四个交付物:**后端服务**、**Web 管理端**(教师 / 管理员)、 班级信息管理系统。一个 pnpm monorepo,一份代码产出四个交付物:**后端服务**、**Web 管理端**(教师 / 管理员)、
**桌面客户端**(ClassHelper 班级端 / 教室机器)与 **ClassIsland 联动插件**(装在教室的 ClassIsland 上)。**当前版本 1.2.0**。 **桌面客户端**(ClassHelper 班级端 / 教室机器)与 **ClassIsland 联动插件**(装在教室的 ClassIsland 上)。
核心链路: 核心链路:
@@ -43,6 +43,13 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
- [4.8 ClassIsland 联动](#48-classisland-联动) - [4.8 ClassIsland 联动](#48-classisland-联动)
- [4.9 数据库管理](#49-数据库管理仅管理员) - [4.9 数据库管理](#49-数据库管理仅管理员)
- [4.10 更新检查](#410-更新检查) - [4.10 更新检查](#410-更新检查)
- [4.11 毕业归档(仅管理员)](#411-毕业归档仅管理员)
- [4.12 学期周次](#412-学期周次可逐周覆盖仅管理员)
- [4.13 课表同步方向](#413-课表同步方向服务端--教室)
- [4.14 权限组管理(角色只是默认模板)](#414-权限组管理角色只是默认模板)
- [4.15 客户端设备统一更新与远控](#415-客户端设备统一更新与远控)
- [4.16 通知按天归档与批量发布](#416-通知按天归档与批量发布)
- [4.17 大模型助手与开发者模式 / 关怀模式](#417-大模型助手与开发者模式--关怀模式)
- [5. 技术参考](#5-技术参考) - [5. 技术参考](#5-技术参考)
- [5.1 目录结构](#51-目录结构) - [5.1 目录结构](#51-目录结构)
- [5.2 技术栈](#52-技术栈) - [5.2 技术栈](#52-技术栈)
@@ -86,8 +93,9 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
| **ClassIsland 联动插件** | `releases/classisland-plugin/<版本>/安装包/ClassHelper.ClassIslandPlugin.cipx` | 装在教室机器的 ClassIsland 上(也可把 `ClassHelper.ClassIslandPlugin/` 目录丢进 Plugins) | | **ClassIsland 联动插件** | `releases/classisland-plugin/<版本>/安装包/ClassHelper.ClassIslandPlugin.cipx` | 装在教室机器的 ClassIsland 上(也可把 `ClassHelper.ClassIslandPlugin/` 目录丢进 Plugins) |
> 这些产物**都不入库**(`releases/` 已 gitignore + eslint ignore)。三个打包脚本**直接输出**到 > 这些产物**都不入库**(`releases/` 已 gitignore + eslint ignore)。三个打包脚本**直接输出**到
> `releases/<组件>/<版本>/`,每个版本目录下按 `安装包/`、`免安装/`、`构建中间/` 分层, > `releases/<组件>/<版本>/`,每个版本目录下按 `安装包/`(交付物)、`构建中间/`(可重新生成)分层,
> 历史版本原样保留 —— 详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §4。 > 历史版本原样保留。**没有免安装目录**:组装/解包出来的那一份只是中间物,脚本打完就删 ——
> 详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §4。
四种部署形态按场景选(Linux 完整步骤见 [docs/management/linux-deploy.md](management/linux-deploy.md), 四种部署形态按场景选(Linux 完整步骤见 [docs/management/linux-deploy.md](management/linux-deploy.md),
其余见 [docs/management/production.md](management/production.md)): 其余见 [docs/management/production.md](management/production.md)):
@@ -97,7 +105,7 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
| 学校机房 / 教师电脑(Windows 单机) | Windows 服务端安装程序(内置 Node,双击即用) | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | | 学校机房 / 教师电脑(Windows 单机) | Windows 服务端安装程序(内置 Node,双击即用) | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` |
| 云服务器 / 多终端共享(推荐长期) | Docker Compose + MySQL(另有 SQLite 单容器版) | `deploy/Dockerfile`、`deploy/docker-compose{,.sqlite}.yml` | | 云服务器 / 多终端共享(推荐长期) | Docker Compose + MySQL(另有 SQLite 单容器版) | `deploy/Dockerfile`、`deploy/docker-compose{,.sqlite}.yml` |
| 已有 Linux 服务器 | **一键安装器 + `classhelper` 运维命令**(systemd / docker) | `sudo bash deploy/install.sh`(先 `--check` 体检);运维见 `classhelper help` | | 已有 Linux 服务器 | **一键安装器 + `classhelper` 运维命令**(systemd / docker) | `sudo bash deploy/install.sh`(先 `--check` 体检);运维见 `classhelper help` |
| 自定义 / 已有 Node 环境 | 手动部署免安装目录 | `pnpm dist:server` → `releases/server/<版本>/免安装/` | | 自定义 / 已有 Node 环境 | 在仓库里构建后直接跑(手动部署) | `pnpm --filter @classhelper/server start` |
**Linux 服务器最快路径是一行命令**:脚本会自己从 GitHub Release 取最新版服务端包、取发布页上的 **Linux 服务器最快路径是一行命令**:脚本会自己从 GitHub Release 取最新版服务端包、取发布页上的
`.sha256` 校验,再装成 systemd 服务并带上 `classhelper` 运维命令。完整步骤与验收清单见 `.sha256` 校验,再装成 systemd 服务并带上 `classhelper` 运维命令。完整步骤与验收清单见
@@ -211,8 +219,7 @@ pnpm verify:desktop
| `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8) | | `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8) |
| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/<版本>/` | | `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/<版本>/` |
| `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 插件 / 官网 / 界面内品牌标) | | `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 插件 / 官网 / 界面内品牌标) |
| `pnpm dist:server` | 打包服务端 + Web 管理端(免安装目录 + NSIS 安装程序,内置 Node) | | `pnpm dist:server` | 打包服务端 + Web 管理端(NSIS 安装程序,内置 Node) |
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(最快) |
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | | `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
| `pnpm dist:all` | 服务端安装程序 + 客户端安装包 + 插件一起打 | | `pnpm dist:all` | 服务端安装程序 + 客户端安装包 + 插件一起打 |
| `pnpm verify:e2e` | 后端端到端验收(需服务端已启动) | | `pnpm verify:e2e` | 后端端到端验收(需服务端已启动) |
@@ -765,14 +772,14 @@ Web 管理端「数据库管理」页(`/database`,仅 ADMIN 可见,后端
| 能力 | 说明 | | 能力 | 说明 |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 连接状态检测 | 当前类型(SQLite/MySQL)、连通性与延迟、引擎版本、数据体积、16 张表行数、连接串(MySQL 密码脱敏) | | 连接状态检测 | 当前类型(SQLite/MySQL)、连通性与延迟、引擎版本、数据体积、23 张表行数、连接串(MySQL 密码脱敏) |
| 连接测试 | 任意目标库的连通性试连(切换前的第一步) | | 连接测试 | 任意目标库的连通性试连(切换前的第一步) |
| **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → prisma generate / db push 建表 → 子进程迁移全部数据 → 改写 .env」,异步任务带步骤进度,失败自动回滚 | | **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → prisma generate / db push 建表 → 子进程迁移全部数据 → 改写 .env」,异步任务带步骤进度,失败自动回滚 |
| 备份 / 恢复 | gzip JSON 快照存 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 | | 备份 / 恢复 | gzip JSON 快照存 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 |
| 定时备份 | enabled + 间隔小时数 + 保留份数,存 `data/database-settings.json`;服务端每 10 分钟检查一次到点任务(进程不运行不补跑) | | 定时备份 | enabled + 间隔小时数 + 保留份数,存 `data/database-settings.json`;服务端每 10 分钟检查一次到点任务(进程不运行不补跑) |
| 快捷导入导出 | 下载快照 JSON(跨库通用)/ 下载数据库文件(仅 SQLite)/ 导入快照(整库覆盖,base64 上传) | | 快捷导入导出 | 下载快照 JSON(跨库通用)/ 下载数据库文件(仅 SQLite)/ 导入快照(整库覆盖,base64 上传) |
关键设计:**备份、导入导出、跨库迁移共用同一种「JSON 快照」**(`lib/snapshot.ts`,16 张表按拓扑序导出, 关键设计:**备份、导入导出、跨库迁移共用同一种「JSON 快照」**(`lib/snapshot.ts`,23 张表按拓扑序导出,
恢复时临时关外键检查 —— `User.classId` 与 `Class.teacherId` 互相引用),因此任意备份都能恢复回任意一种受支持的数据库。 恢复时临时关外键检查 —— `User.classId` 与 `Class.teacherId` 互相引用),因此任意备份都能恢复回任意一种受支持的数据库。
MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-mariadb`(已在依赖里,不再是「切换时手动安装」)。 MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-mariadb`(已在依赖里,不再是「切换时手动安装」)。
@@ -785,7 +792,8 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
> - 安装包体积因此增大(约 34MB → **62MB**):切换需要随包内置 prisma CLI 与 TypeScript 编译器 > - 安装包体积因此增大(约 34MB → **62MB**):切换需要随包内置 prisma CLI 与 TypeScript 编译器
> (Prisma 7 的生成器产出 .ts 源码,切换后要在目标机编译进 dist)。 > (Prisma 7 的生成器产出 .ts 源码,切换后要在目标机编译进 dist)。
> 打包末尾会做一次**运行时裁剪**(删掉 Prisma 为其它数据库方言准备的查询编译器副本等死载荷, > 打包末尾会做一次**运行时裁剪**(删掉 Prisma 为其它数据库方言准备的查询编译器副本等死载荷,
> 见 `scripts/dist-server.mjs` 的 `pruneRuntime()`),免安装目录 469MB → 335MB。 > 见 `scripts/dist-server.mjs` 的 `pruneRuntime()`),组装出来的运行时 469MB → 335MB
> (这份运行时现在只是中间物,打完安装程序即删)。
> - 真实切换(SQLite→MySQL)已在本机用完整子进程链路实测(快照 728 行逐表一致);e2e 只覆盖校验分支 > - 真实切换(SQLite→MySQL)已在本机用完整子进程链路实测(快照 728 行逐表一致);e2e 只覆盖校验分支
> (同库 400 / 非法 provider 422 / 目标库非空拒绝),避免测试改写 `.env`。 > (同库 400 / 非法 provider 422 / 目标库非空拒绝),避免测试改写 `.env`。
@@ -824,8 +832,6 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
只提示、不自动下载、不自动安装 —— 安装包仍由人从 Release 页取(`SHA256SUMS-<版本>.txt` 可核对)。 只提示、不自动下载、不自动安装 —— 安装包仍由人从 Release 页取(`SHA256SUMS-<版本>.txt` 可核对)。
## 5. 技术参考
### 4.11 毕业归档(仅管理员) ### 4.11 毕业归档(仅管理员)
按**年度**把毕业的班级与学生归档,并留档当年转出的学生、以及毕业班级发过的作业与通知。 按**年度**把毕业的班级与学生归档,并留档当年转出的学生、以及毕业班级发过的作业与通知。
@@ -892,6 +898,117 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
> 插件侧对应 `ReportSettingsDto.scheduleRequested` 与 `ReportAsync(..., forceSchedule: true)`; > 插件侧对应 `ReportSettingsDto.scheduleRequested` 与 `ReportAsync(..., forceSchedule: true)`;
> 补发发生在**镜像之前**(顺序反了会把刚拿到的课表盖掉)。 > 补发发生在**镜像之前**(顺序反了会把刚拿到的课表盖掉)。
### 4.14 权限组管理(角色只是默认模板)
入口:Web 管理端 →「权限组管理」。在这之前,账号能干什么完全由角色(`ADMIN` / `TEACHER`)决定;
现在多了一层**权限组**,角色退化成「没有分组时」的默认模板。
| 账号 | 生效权限 |
| ------------------------- | ----------------------------------------------------------------------------- |
| `ADMIN` 管理员 | 全集;`permission:manage` 与 `system:manage` **不可被剥夺**(避免把自己锁死) |
| 有分组的账号 | 所属权限组权限键的**并集** —— 因此可以比角色默认**更少**(用来收回权限) |
| 没有任何分组的账号 | 回落角色默认模板 `defaultPermissionsForRole(role)` |
| `CLASS_DEVICE` 班级端账号 | **空集,不受权限组影响**;它的能力由班级维度断言决定 |
- 生效集合的**唯一计算入口**是 `src/lib/permissions.ts` 的 `loadEffectivePermissions()`,
**每请求实时查询、不缓存** —— 改完权限立即生效(代价是每请求多一次分组查询)。
- 权限键目录(`PERMISSION_KEYS` / `PERMISSION_LABELS` / `PERMISSION_CATEGORIES`)只有一份,
放在 `@classhelper/shared`:权限页的勾选树与后端的 `requirePermission` 读的是同一份。
- 保存权限组时是**整体替换**权限键(漏传的等于取消勾选,不会留下幽灵权限);
`builtin` 内置组可以改名、可以调键,但**不能删除**;删掉一个组后原成员回到角色默认模板。
- 端点:`GET /permissions/me`(**无权限键**,两端据此决定入口显不显示)、`GET /permissions/catalog`、
`GET / POST /permissions/groups`、`PATCH / DELETE /permissions/groups/:id`、`GET /permissions/users`、
`PUT /permissions/users/:id/groups`(除 `/me` 外都要求 `permission:manage`)。
- 判定规则与完整矩阵见[角色与权限](dev/permissions.md);**服务端始终是唯一拦截点**,
前端只按 `GET /permissions/me` 隐藏入口,别把它当安全边界。
### 4.15 客户端设备统一更新与远控
每台教室机器的心跳间隔 `CLIENT_HEARTBEAT_INTERVAL_MS` = **30 秒**,一次请求把两件事带回:
「要不要更新 / 装哪个版本」与「有没有待执行的远控命令」(`POST /clients/heartbeat`,**不挂权限键**)。
| 项 | 口径 |
| ---------------- | ------------------------------------------------------------------------------ |
| 在线判定 | 最后一次心跳在 `CLIENT_ONLINE_WINDOW_MS`(90 秒)内算在线 —— 心跳间隔的 3 倍 |
| 命令有效期 | `CLIENT_COMMAND_EXPIRE_MS`(10 分钟);机器离线几天后不会被突然关机 |
| 命令种类 | 关机 / 重启 / 注销 / 锁屏(`CLIENT_COMMAND_KINDS` + `CLIENT_COMMAND_LABELS`) |
| 新命令取代旧命令 | 同一台机器未回执的旧命令会被自动撤销(先关机后重启不会排队执行) |
| 回执 | `POST /clients/commands/:id/ack`,body 里的 `deviceKey` 既是身份也是护栏(班级端令牌全班共用),且**幂等** |
| 二次确认 | 客户端执行前必须在界面上二次确认;回执里带成功 / 失败原因 |
**安装包由服务端中继下发**(教室机器常常没有外网,Web 端的 CSP 也不允许直连 GitHub):
- `POST /update/client/fetch` 让服务端代下并登记 sha256 + size;下不动时用
`POST /update/client/releases` 手工登记(文件先放进 `data/client-releases/<版本>/`);
- 缓存在 `<serverRoot>/../data/client-releases/<版本>/`,版本号与文件名都要过白名单
`^[0-9A-Za-z._-]+$`(**防路径穿越**);
- 客户端从 `GET /update/client/download/:version` 取包(**故意不挂权限键**,班级端要能下载);
- 安装时机三个条件缺一不可:**不在上课、主窗口已隐藏满 `CLIENT_UPDATE_IDLE_MS`(5 分钟)**、包已下载。
权限三键:`remote:control`(下发远控命令)、`client:manage`(`GET /clients/devices` 列表、移除设备)、
`client:update-push`(下发更新、登记与删除安装包)。
### 4.16 通知按天归档与批量发布
通知多了以后「哪天发了什么」比「什么时候创建的」更重要,所以通知也跟作业一样有了**所属日期**:
| 项 | 说明 |
| ------------ | ------------------------------------------------------------------------------- |
| `assignDate` | `Notification.assignDate` 是**本地日期**(创建时 `dayKeyLocal()` 写入),不是 `createdAt` 切 UTC |
| 索引 | `@@index([classId, assignDate])` 支撑按天 / 区间查询 |
| 按天归档 | `GET /notifications/days`(日期 + 条数)、`GET /notifications/creators`(按发布人) |
| 列表筛选 | `GET /notifications?date=&from=&to=&createdBy=`(与 `classId` / `priority` / `unreadOnly` / `keyword` 叠加) |
| 批量发布 | `POST /notifications/bulk`(`notification:publish`)、`POST /homeworks/bulk`(`homework:write`) |
**批量发布逐班判定、绝不整批回滚**:
```ts
BulkPublishResult { total, succeeded, failed, results: BulkPublishClassResult[] }
BulkPublishClassResult { classId, ok, id?, code?, message?, details? }
```
某个班没权限(403)或撞上「上课时段只许发紧急通知」只让**那一个班**失败,其它班照常落库并实时推送;
失败项带可直接展示的中文 `message`,界面按班列出来。作业批量里每个班可以给 `subjectName`,
服务端按**科目名**在该班匹配课程 —— 匹配不上就是那个班失败,不会退化成"挂到班主任名下"。
> **不要用 `toISOString().slice(0, 10)` 算"哪一天"**:它按 UTC 切,晚上 8 点以后发的通知会算到第二天。
> 统一用 `@classhelper/shared` 的 `dayKeyLocal / isDayKey / shiftDayKey`。
### 4.17 大模型助手与开发者模式 / 关怀模式
**大模型助手**(Web 管理端 →「AI 助手」)用一句话办事,但助手**只解析、不落库**:
| 环节 | 口径 |
| -------- | --------------------------------------------------------------------------------------- |
| 设置 | `GET / PUT /ai/settings`(`ai:manage`):开关 / provider / `baseUrl` / 模型 / 提示词补充 |
| 密钥 | API Key 用 **AES-256-GCM** 加密后存 `AppSetting`(密钥由 `JWT_SECRET` 经 scrypt 派生),**只进不出**:GET 只回 `hasApiKey` 与 `keyHint`(末 4 位) |
| 解析 | `POST /ai/interpret`(`ai:use`)→ `AiInterpretResult{ action, params, preview, confidence, needConfirm }` |
| 动作 | `action ∈ create_homework / create_notification / call_student / unknown` |
| 二次校验 | `params` 由服务端用**与手动入口同一份 zod** 再校验一遍,不合规直接判 `unknown` |
| 落库 | 前端展示 `preview`(班级 / 科目 / 学生已解析成中文),用户确认后调**既有端点**发布 —— 权限与上课时段保护都还在那条路径上 |
| provider | `openai` 走 `{baseUrl}/chat/completions`;`mock` 是**离线确定性解析**(自动化验收不联网) |
| 审计 | 每次解析写一条 `AiAudit`;`GET /ai/audit`(`ai:manage`)可查谁在什么时候让助手做了什么 |
**系统设置**(`system:manage`,管理员)入口在「大模型设置」页底部的两张卡片:
- `GET / PUT /system/settings`:`developerMode` 开启后 **500 响应会附带 `error.stack`**
(排查"服务端到底报了什么"),默认关闭;
- `GET / PUT /system/autostart`:Windows 改
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ClassHelperServer`,Linux 走
`systemctl enable/disable classhelper`;**非安装形态(开发机 / 免安装 / 容器)返回 `supported:false`**
并给出原因,不假装成功。安装器 `scripts/nsis/server-installer.nsi` 已把开机自启做成
**组件页上的可选 Section(默认勾选)**;
- `GET /system/diagnostics`:运行诊断(版本 / Node / 平台 / 运行时长 / 数据库 provider /
在线客户端数 / 已缓存安装包数 / 已加载模块数 / 服务端根目录 / 数据目录,**不含任何密钥**)。
**关怀模式与菜单回缩**(纯界面偏好,不落服务端):Web 端把「关怀模式」存在 `localStorage`
(`classhelper.admin.careMode` + `careScale`,1.0~1.5、默认 1.2),开启时给 `<html>` 挂 `care-mode`
并把倍数写进 `--ch-font-scale`;客户端把 `careMode` 存本机 `config.json`,同口径放大界面文字。
侧栏折叠同样是本机偏好。`User.careMode` 列已经建好,但**服务端目前没有读写它** ——
别把它当成"已经有服务端开关"。
## 5. 技术参考
### 5.1 目录结构 ### 5.1 目录结构
``` ```
@@ -907,7 +1024,7 @@ class-helper/
│ ├── use-database.mjs # SQLite ⇄ MySQL provider 切换助手(改写 schema.prisma 的 provider) │ ├── use-database.mjs # SQLite ⇄ MySQL provider 切换助手(改写 schema.prisma 的 provider)
│ ├── generate-icons.mjs # 启动 Electron 跑 icons/render.cjs(= pnpm icons) │ ├── generate-icons.mjs # 启动 Electron 跑 icons/render.cjs(= pnpm icons)
│ ├── icons/render.cjs # 把 classhelper.png 重采样成各端图标(CommonJS) │ ├── icons/render.cjs # 把 classhelper.png 重采样成各端图标(CommonJS)
│ ├── dist-server.mjs # 服务端 + Web 管理端 打包(免安装目录 + NSIS 安装程序) │ ├── dist-server.mjs # 服务端 + Web 管理端 打包(组装运行时 → NSIS 安装程序)
│ ├── verify-packaged.mjs # 对打包后 / 已安装的客户端 EXE 复跑冒烟 │ ├── verify-packaged.mjs # 对打包后 / 已安装的客户端 EXE 复跑冒烟
│ ├── lib/electron-env.mjs # 启动 Electron 的公共处理(清理 RUN_AS_NODE + 定位可执行文件) │ ├── lib/electron-env.mjs # 启动 Electron 的公共处理(清理 RUN_AS_NODE + 定位可执行文件)
│ ├── lib/node-runtime.mjs # 定位真正的 node.exe(打包与 electron-builder 用) │ ├── lib/node-runtime.mjs # 定位真正的 node.exe(打包与 electron-builder 用)
@@ -938,8 +1055,8 @@ class-helper/
├── shared/ # 三端共享:类型契约、常量、权限矩阵、工具函数 ├── shared/ # 三端共享:类型契约、常量、权限矩阵、工具函数
│ └── src/{types,constants,permissions,utils,index}.ts │ └── src/{types,constants,permissions,utils,index}.ts
├── server/ # 后端服务 ├── server/ # 后端服务
│ ├── prisma/schema.prisma # 数据模型(14 个 model) │ ├── prisma/schema.prisma # 数据模型(24 个 model)
│ ├── prisma/migrations/ # 迁移历史(10 个) │ ├── prisma/migrations/ # 迁移历史(17 个)
│ ├── prisma/seed.ts # 种子数据(会清空业务表后重建演示数据) │ ├── prisma/seed.ts # 种子数据(会清空业务表后重建演示数据)
│ ├── prisma.config.ts # Prisma 7 配置(schema / 迁移 / 种子 / 连接串) │ ├── prisma.config.ts # Prisma 7 配置(schema / 迁移 / 种子 / 连接串)
│ ├── prisma/backups/ # 备份快照与定时配置(运行期生成,已 gitignore) │ ├── prisma/backups/ # 备份快照与定时配置(运行期生成,已 gitignore)
@@ -957,7 +1074,7 @@ class-helper/
│ ├── tools/apply-snapshot.ts # 跨库迁移的子进程入口(编译到 dist/tools/) │ ├── tools/apply-snapshot.ts # 跨库迁移的子进程入口(编译到 dist/tools/)
│ ├── middleware/ # auth / error / validate / security(helmet+限流+耗时日志) │ ├── middleware/ # auth / error / validate / security(helmet+限流+耗时日志)
│ ├── realtime/ # socket.ts + bus.ts(事件总线) │ ├── realtime/ # socket.ts + bus.ts(事件总线)
│ └── modules/ # 15 个功能模块 + registry.ts(模块注册表) │ └── modules/ # 21 个功能模块 + registry.ts(模块注册表)
│ ├── database/ # 数据库管理(状态/备份/导入导出/定时/一键切换,仅管理员) │ ├── database/ # 数据库管理(状态/备份/导入导出/定时/一键切换,仅管理员)
│ └── update/ # 更新检查(查 GitHub 最新 Release,服务端唯一出网请求) │ └── update/ # 更新检查(查 GitHub 最新 Release,服务端唯一出网请求)
├── classisland-plugin/ # ClassIsland 联动插件(.NET 8 / C#,独立于 pnpm workspace) ├── classisland-plugin/ # ClassIsland 联动插件(.NET 8 / C#,独立于 pnpm workspace)
@@ -1042,7 +1159,7 @@ class-helper/
| PATCH / DELETE | `/homeworks/:id` | 教师 / 管理员 | 修改 / 删除(广播 `homework:new` / `homework:updated`) | | PATCH / DELETE | `/homeworks/:id` | 教师 / 管理员 | 修改 / 删除(广播 `homework:new` / `homework:updated`) |
| PATCH | `/homeworks/:id/status` | 登录 | 标记完成 / 取消(广播 `homework:status`) | | PATCH | `/homeworks/:id/status` | 登录 | 标记完成 / 取消(广播 `homework:status`) |
| GET / PATCH | `/homeworks/:id/submissions` | 登录 | 未交名单:读名单 / 回写全班完成状态 | | GET / PATCH | `/homeworks/:id/submissions` | 登录 | 未交名单:读名单 / 回写全班完成状态 |
| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) | | GET | `/notifications?classId=&priority=&unreadOnly=&keyword=&date=&from=&to=&createdBy=` | 登录 | 通知列表(带已读状态;可按所属日期与发布人筛选) |
| GET | `/notifications/unread-count` | 登录 | 未读数(红点) | | GET | `/notifications/unread-count` | 登录 | 未读数(红点) |
| POST | `/notifications` | 教师 / 管理员 | 发布通知(广播 `notification:new`);上课时段发布紧急通知需 `confirmDuringClass` | | POST | `/notifications` | 教师 / 管理员 | 发布通知(广播 `notification:new`);上课时段发布紧急通知需 `confirmDuringClass` |
| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读(班级设备会写全班) | | POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读(班级设备会写全班) |
@@ -1068,7 +1185,7 @@ class-helper/
| GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) | | GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) |
| POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) | | POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) |
| POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) | | POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) |
| GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 16 张表行数 / 备份列表 / 定时配置) | | GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 23 张表行数 / 备份列表 / 定时配置) |
| POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) | | POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) |
| GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) | | GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) |
| POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 | | POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 |
@@ -1082,6 +1199,38 @@ class-helper/
| GET | `/integrations/classisland/class-plan` | **设备令牌** | 插件拉取本班课表(开启镜像时)用于写回 ClassIsland | | GET | `/integrations/classisland/class-plan` | **设备令牌** | 插件拉取本班课表(开启镜像时)用于写回 ClassIsland |
| GET | `/update/check` | 登录 | 更新检查(服务端代查 GitHub 最新 Release;`?force=1` 绕过缓存仅管理员生效) | | GET | `/update/check` | 登录 | 更新检查(服务端代查 GitHub 最新 Release;`?force=1` 绕过缓存仅管理员生效) |
| GET | `/health` | 公开 | 健康检查(含版本号与已挂载模块);另有 `/healthz` 存活与 `/readyz` 就绪探针 | | GET | `/health` | 公开 | 健康检查(含版本号与已挂载模块);另有 `/healthz` 存活与 `/readyz` 就绪探针 |
| GET | `/permissions/me` | 登录 | 我的生效权限(**无权限键**;班级端会话返回空集),两端据此隐藏入口 |
| GET | `/permissions/catalog` | `permission:manage` | 权限键目录(分类 / 名称,供勾选树用) |
| GET / POST | `/permissions/groups` | `permission:manage` | 权限组列表 / 新建 |
| PATCH / DELETE | `/permissions/groups/:id` | `permission:manage` | 改名 / 改描述 / 整体替换权限键;内置组不可删 |
| GET | `/permissions/users` | `permission:manage` | 账号列表(带各自所属权限组) |
| PUT | `/permissions/users/:id/groups` | `permission:manage` | 设置账号所属权限组(空数组 = 回落角色默认模板) |
| POST | `/clients/heartbeat` | 登录 | 客户端心跳(**无权限键**,班级端也调):返回更新目标 + 一条待执行命令 |
| POST | `/clients/commands/:id/ack` | 登录 | 命令回执(**无权限键**;body 的 `deviceKey` 校验归属,幂等) |
| POST | `/clients/commands` | `remote:control` | 下发远控命令(关机 / 重启 / 注销 / 锁屏;自动撤销该机未回执的旧命令) |
| GET | `/clients/devices?classId=` | `client:manage` | 客户端设备列表(在线判定窗口 90 秒) |
| POST | `/clients/updates` | `client:update-push` | 给指定设备下发更新目标版本 |
| DELETE | `/clients/devices/:id` | `client:manage` | 移除设备记录 |
| GET | `/update/client/releases` | `client:manage` | 已缓存的客户端安装包列表 |
| POST | `/update/client/fetch` | `client:update-push` | 服务端代下 GitHub 上的安装包并登记 sha256 + size |
| POST | `/update/client/releases` | `client:update-push` | 手工登记安装包(文件先放进 `data/client-releases/<版本>/`) |
| DELETE | `/update/client/releases/:version` | `client:update-push` | 删除缓存的安装包 |
| GET | `/update/client/download/:version` | 登录 | 下载安装包(**故意不挂权限键**,班级端要能下载;版本号白名单防路径穿越) |
| POST | `/homeworks/bulk` | `homework:write` | 跨班批量发布作业(每班可选 `subjectName`,按科目名在该班匹配) |
| GET | `/notifications/days?classId=&from=&to=&createdBy=` | 登录 | 哪些天有通知(按天归档,供日期高亮) |
| GET | `/notifications/creators?classId=&from=&to=&createdBy=&date=` | 登录 | 发布人列表(含每人条数) |
| POST | `/notifications/bulk` | `notification:publish` | 跨班批量发布通知(逐班判定,单班失败不影响整批) |
| GET / PUT | `/ai/settings` | `ai:manage` | 大模型设置(API Key 加密存储、**只进不出**,只回 `hasApiKey` / `keyHint`) |
| POST | `/ai/interpret` | `ai:use` | 助手解析自然语言(**只解析不落库**,返回 `action` / `params` / `preview`) |
| GET | `/ai/audit?limit=` | `ai:manage` | 助手使用审计(谁在什么时候让助手做了什么) |
| GET / PUT | `/system/settings` | `system:manage` | 系统设置(`developerMode`:开启后 500 响应带 `error.stack`) |
| GET / PUT | `/system/autostart` | `system:manage` | 服务端开机自启状态 / 开关(非安装形态 `supported:false` 并给原因) |
| GET | `/system/diagnostics` | `system:manage` | 运行诊断(版本 / Node / 运行时长 / 在线客户端数 / 数据目录…,不含密钥) |
> 新增的这几组用**权限键**(`permission:manage` / `client:manage` / `remote:control` /
> `client:update-push` / `ai:use` / `ai:manage` / `system:manage` / `homework:write` /
> `notification:publish`)标权限,规则见 [§5.5](#55-角色与权限rbac) 与
> [角色与权限](dev/permissions.md);上表其余行写的是**角色默认模板**下的口径。
### 5.4 WebSocket 事件 ### 5.4 WebSocket 事件
@@ -1147,28 +1296,45 @@ class-helper/
- 成绩相关写入按「老师端可用 + 不越权」实现(需求 6 / 7 口径):录入 / 修改 / 删除成绩与成绩模板下载 / 预览 / 导入 - 成绩相关写入按「老师端可用 + 不越权」实现(需求 6 / 7 口径):录入 / 修改 / 删除成绩与成绩模板下载 / 预览 / 导入
都是「管理员 + 本班班主任」,学生名单模板与导入仅管理员,班科任老师一律 403 —— 服务层兜底,前端只是按同一矩阵隐藏。 都是「管理员 + 本班班主任」,学生名单模板与导入仅管理员,班科任老师一律 403 —— 服务层兜底,前端只是按同一矩阵隐藏。
**权限组:角色只是默认模板**(完整规则见[角色与权限](dev/permissions.md)):
- 上面的矩阵描述的是**角色默认模板**(`defaultPermissionsForRole`)—— 它只在账号**没有加入任何权限组**时生效。
- 账号可以加入若干权限组,生效权限 = 这些组权限键的**并集**,因此可以比角色默认**更少**
(要收回权限就建一个只勾部分键的组);删掉组后成员回到角色默认模板,不会变成空权限。
- `ADMIN` 恒定全集,`permission:manage` / `system:manage` **不可被剥夺**(否则可能把自己锁在外面);
`CLASS_DEVICE` 恒定空集,不受权限组影响。
- 唯一计算入口是 `src/lib/permissions.ts` 的 `loadEffectivePermissions()`,**每请求实时查询、不缓存**;
路由上的 `requirePermission('<key>')` 与权限页的勾选树共用 `@classhelper/shared` 里同一份权限键目录,
`PERMISSION_MATRIX` 与它不再是两套规则(矩阵就是默认模板的说明)。
### 5.6 模块化设计 ### 5.6 模块化设计
后端每个功能是一个独立目录,统一契约 `ApiModule = { name, basePath, router, enabled? }`: 后端每个功能是一个独立目录,统一契约 `ApiModule = { name, basePath, router, enabled? }`:
```ts ```ts
// packages/server/src/modules/registry.ts(当前 15 个模块) // packages/server/src/modules/registry.ts(当前 21 个模块)
export const apiModules: ApiModule[] = [ export const apiModules: ApiModule[] = [
authModule, authModule,
aiModule, // ← 大模型助手
classesModule, classesModule,
clientsModule, // ← 客户端设备 / 心跳 / 远控
coursesModule, coursesModule,
schedulesModule, schedulesModule,
homeworksModule, homeworksModule,
notificationsModule, notificationsModule,
permissionsModule, // ← 权限组
callsModule, callsModule,
importsModule, importsModule,
integrationsModule, // ← ClassIsland 联动 integrationsModule, // ← ClassIsland 联动
gradesModule, gradesModule,
studentsModule, studentsModule,
systemModule, // ← 系统设置(开发者模式 / 开机自启 / 诊断)
teachersModule, teachersModule,
archivesModule,
termModule,
dashboardModule, dashboardModule,
databaseModule, // ← 数据库管理(仅管理员) databaseModule, // ← 数据库管理(仅管理员)
updateModule, // ← 更新检查(查 GitHub 最新 Release) updateModule, // ← 更新检查 + 客户端安装包(查 GitHub 最新 Release)
].filter((module) => module.enabled !== false); ].filter((module) => module.enabled !== false);
``` ```
@@ -1192,21 +1358,28 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
> MySQL 驱动适配器 `@prisma/adapter-mariadb` **已随包内置**(数据库管理模块的连接测试 / 迁移需要),无需手动安装。 > MySQL 驱动适配器 `@prisma/adapter-mariadb` **已随包内置**(数据库管理模块的连接测试 / 迁移需要),无需手动安装。
数据模型共 **16 张表**:`User`(纯账号表)/ `Student`(名单,不是账号)/ `StudentClassTransfer`(调班与转出历史)/ 数据模型共 **24 张表**:`User`(纯账号表)/ `Student`(名单,不是账号)/ `StudentClassTransfer`(调班与转出历史)/
`ArchivedYear`(毕业归档届别)/ `Class` / `TermWeek`(学期周次区间)/ `Course`(= 班级 + 科目 + 教师的任课关系)/ `ArchivedYear`(毕业归档届别)/ `Class` / `TermWeek`(学期周次区间)/ `Course`(= 班级 + 科目 + 教师的任课关系)/
`Schedule` / `Homework` / `HomeworkStatus` / `Notification` / `NotificationRead` / `TimeLayout` / `Grade` / `Schedule` / `Homework` / `HomeworkStatus` / `Notification` / `NotificationRead` / `TimeLayout` / `Grade` /
`IntegrationDevice` / `ClassIslandPush`。 `IntegrationDevice` / `ClassIslandPush` / `PermissionGroup` / `PermissionGroupItem` / `UserPermissionGroup`(权限组)/
`AppSetting`(全局键值设置)/ `ClientDevice` / `ClientCommand`(客户端设备与远控)/ `ClientRelease`(安装包缓存)/
`AiAudit`(大模型审计)。
其中 `Class.name` 是「2026级1班」这种**届别称呼**;`Class.code` / `Class.passwordHash` 是班级端账号字段; 其中 `Class.name` 是「2026级1班」这种**届别称呼**;`Class.code` / `Class.passwordHash` 是班级端账号字段;
`Class.notificationChannel` 决定提醒弹在哪端;`Class.studentGradeQueryEnabled` 是班级端查成绩的开关; `Class.notificationChannel` 决定提醒弹在哪端;`Class.studentGradeQueryEnabled` 是班级端查成绩的开关;
`Student.studentNo` 是学生的唯一标识与查询键;`Grade.levelType` / `level` 是等级口径与等级; `Student.studentNo` 是学生的唯一标识与查询键;`Grade.levelType` / `level` 是等级口径与等级;
`Homework.assignDate` 是作业的「所属日期」;`Schedule.weekParity` 是单双周;`Schedule.source` 是课表来源; `Homework.assignDate` 是作业的「所属日期」;`Notification.assignDate` 是通知的「所属日期」(本地日期,
`ClassIslandPush.kind` 区分通知类与叫人。 配 `@@index([classId, assignDate])` 支撑按天查询);`User.careMode` 是关怀模式的预留列(服务端暂未读写);
`Schedule.weekParity` 是单双周;`Schedule.source` 是课表来源;`ClassIslandPush.kind` 区分通知类与叫人。
迁移历史(**16 个**,按时间顺序):`init` → `add_time_layout` → `add_class_account` → `add_schedule_week_parity` → 备份 / 导出导入共用 `src/lib/snapshot.ts` 的 `SNAPSHOT_TABLES`,按拓扑序导出 **23 张**
(`TermWeek` 不在其中 —— 学期周次不进快照,换库后按开学日期重新生成)。
迁移历史(**17 个**,按时间顺序):`init` → `add_time_layout` → `add_class_account` → `add_schedule_week_parity` →
`add_class_term_weeks` → `add_classisland_integration` → `add_class_notification_channel` → `add_class_term_weeks` → `add_classisland_integration` → `add_class_notification_channel` →
`add_homework_assign_date` → `add_push_kind` → `add_schedule_source` → `add_homework_assign_date` → `add_push_kind` → `add_schedule_source` →
`add_student_table` → `add_student_grade_query` → `add_grade_level` → `add_student_table` → `add_student_grade_query` → `add_grade_level` →
`add_classhelper_heartbeat` → `add_archives` → `add_term_weeks`。 `add_classhelper_heartbeat` → `add_archives` → `add_term_weeks` → `add_permission_groups_and_clients`
(最后一个只加表 / 加列:8 张新表 + `Notification.assignDate` 与索引 + `User.careMode`,无数据搬运)。
**启动时迁移(安装版自动升级)**:`src/lib/db-bootstrap.ts` 用一张账本表 `_ch_migrations` 记录已执行的迁移目录名 —— **启动时迁移(安装版自动升级)**:`src/lib/db-bootstrap.ts` 用一张账本表 `_ch_migrations` 记录已执行的迁移目录名 ——
全新安装依次执行全部迁移并创建初始管理员;覆盖安装只补跑账本里没有的迁移(已存在的表 / 索引自动跳过), 全新安装依次执行全部迁移并创建初始管理员;覆盖安装只补跑账本里没有的迁移(已存在的表 / 索引自动跳过),
@@ -1307,15 +1480,15 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
| **登录页无示例内容** | ✅ | `verify:web`(管理端)与 `verify:desktop`(客户端)都断言登录页正文 / 占位符不含 `演示 / 示例 / admin123 / teacher123 / G101` | | **登录页无示例内容** | ✅ | `verify:web`(管理端)与 `verify:desktop`(客户端)都断言登录页正文 / 占位符不含 `演示 / 示例 / admin123 / teacher123 / G101` |
| **新手引导(首启弹出 + 随时重看)** | ✅ | Web 管理端:首次登录弹出 `el-tour` 聚焦引导,头像菜单「使用引导」重看(`verify:web`);客户端:首启六步向导,登录页 / 设置页可重看(`verify:desktop`) | | **新手引导(首启弹出 + 随时重看)** | ✅ | Web 管理端:首次登录弹出 `el-tour` 聚焦引导,头像菜单「使用引导」重看(`verify:web`);客户端:首启六步向导,登录页 / 设置页可重看(`verify:desktop`) |
| **客户端设置改版(分组侧栏 / 深色模式 / 关于页)** | ✅ | `verify:desktop`:「侧边栏点击导航」适配「学习 / 设置」分组;「主题切换与侧边栏折叠(真实点击、写回配置)」——`html.dark` 翻转 + 折叠宽度 200→64 + 双双落盘;「关于页渲染」含 configPath | | **客户端设置改版(分组侧栏 / 深色模式 / 关于页)** | ✅ | `verify:desktop`:「侧边栏点击导航」适配「学习 / 设置」分组;「主题切换与侧边栏折叠(真实点击、写回配置)」——`html.dark` 翻转 + 折叠宽度 200→64 + 双双落盘;「关于页渲染」含 configPath |
| **数据库管理(仅管理员:状态 / 备份 / 导入导出 / 一键切换)** | ✅ | `verify:e2e` 13 项:状态与 14 表行数、教师全端点 403、连接测试(当前库可达 / 不可达 MySQL ok=false)、**Redis 主库 422**、切换校验分支(同库 400)、备份 → 快照导入 → 备份恢复 roundtrip(临时数据被清除)、定时配置校验、SQLite 文件下载;**切换子进程链路真机实测**:快照 728 行 → generate → db push → apply-snapshot 逐表一致;`verify:web`:数据库管理页(仅管理员)状态卡与备份表渲染 | | **数据库管理(仅管理员:状态 / 备份 / 导入导出 / 一键切换)** | ✅ | `verify:e2e` 13 项:状态与全部数据表行数、教师全端点 403、连接测试(当前库可达 / 不可达 MySQL ok=false)、**Redis 主库 422**、切换校验分支(同库 400)、备份 → 快照导入 → 备份恢复 roundtrip(临时数据被清除)、定时配置校验、SQLite 文件下载;**切换子进程链路真机实测**:快照 728 行 → generate → db push → apply-snapshot 逐表一致;`verify:web`:数据库管理页(仅管理员)状态卡与备份表渲染 |
| **更新检查(服务端 / Web 端 / 客户端)** | ✅ | `verify:e2e` 4 项:未登录 401、管理员结构完整且本机版本取自 package.json、教师可查但 `force` 仅管理员生效、健康检查暴露版本号与 15 个模块;`verify:desktop` 4 项:IPC 返回结构正确(无外网时降级 `ok=false` 且带原因)、结果带缓存、冒烟模式跳过启动自动检查、关于页出现「检查更新」;`verify:web`:「顶栏用户菜单 → 弹窗给出结论并可关闭」。**真机联网实测**:`GET /api/update/check` → `ok=true`、`latestVersion=1.0.0`、5 个附件、更新说明 1358 字 | | **更新检查(服务端 / Web 端 / 客户端)** | ✅ | `verify:e2e` 4 项:未登录 401、管理员结构完整且本机版本取自 package.json、教师可查但 `force` 仅管理员生效、健康检查暴露版本号与模块清单(含 update 模块);`verify:desktop` 4 项:IPC 返回结构正确(无外网时降级 `ok=false` 且带原因)、结果带缓存、冒烟模式跳过启动自动检查、关于页出现「检查更新」;`verify:web`:「顶栏用户菜单 → 弹窗给出结论并可关闭」。**真机联网实测**:`GET /api/update/check` → `ok=true`、`latestVersion=1.0.0`、5 个附件、更新说明 1358 字 |
| **其他页面发通知也会联动 ClassIsland** | ✅ | `verify:classisland`:「通知发布页发的通知也会推送」「叫人也会推送」;2026-09-27 在真机(ClassIsland 2.1.0.1 + 本插件)上实测过,当时的抓图留在 `docs/screenshots/classisland/` | | **其他页面发通知也会联动 ClassIsland** | ✅ | `verify:classisland`:「通知发布页发的通知也会推送」「叫人也会推送」;2026-09-27 在真机(ClassIsland 2.1.0.1 + 本插件)上实测过,当时的抓图留在 `docs/screenshots/classisland/` |
| **作业按天查看(日期选择器 + 有作业日期高亮)** | ✅ | `verify:e2e`:`assignDate` 默认服务器当天、`?date=` 只返回该天、`/homeworks/days` 返回有作业日期、非法日期 422 | | **作业按天查看(日期选择器 + 有作业日期高亮)** | ✅ | `verify:e2e`:`assignDate` 默认服务器当天、`?date=` 只返回该天、`/homeworks/days` 返回有作业日期、非法日期 422 |
| **支持在客户端录入作业(含自定义快捷短语)** | ✅ | `verify:e2e`:班级账号录入 201(归属班主任)、跨班 403;客户端录入弹窗 + 「设置 → 通用」管理短语 | | **支持在客户端录入作业(含自定义快捷短语)** | ✅ | `verify:e2e`:班级账号录入 201(归属班主任)、跨班 403;客户端录入弹窗 + 「设置 → 通用」管理短语 |
| **提醒弹在哪个端由客户端自行选择** | ✅ | 客户端「设置 → 提醒」三选一(`verify:desktop`:设置页真实点击 → 写回本地配置);服务端按班级设置决定是否推送(`verify:classisland`:`client` 时通知与联动页下发都被跳过) | | **提醒弹在哪个端由客户端自行选择** | ✅ | 客户端「设置 → 提醒」三选一(`verify:desktop`:设置页真实点击 → 写回本地配置);服务端按班级设置决定是否推送(`verify:classisland`:`client` 时通知与联动页下发都被跳过) |
| **ClassHelper能读取 ClassIsland 的课表** | ✅ | `verify:classisland`:插件上报课表 → 本班课表新增 / 更新(重复上报幂等)、节次时间写入、设备状态快照可在 Web 端展示;`verify:web`:教师在「ClassIsland 联动」页真实签发设备令牌 | | **ClassHelper能读取 ClassIsland 的课表** | ✅ | `verify:classisland`:插件上报课表 → 本班课表新增 / 更新(重复上报幂等)、节次时间写入、设备状态快照可在 Web 端展示;`verify:web`:教师在「ClassIsland 联动」页真实签发设备令牌 |
| **用户可选择通知是否在 ClassIsland 上显示** | ✅ | 插件设置页开关「接收 ClassHelper提醒」(本机总闸)+ Web 端「下发提醒」(标题 / 内容 / 时长 / 优先级 / 语音朗读 / 是否同步通知中心);`verify:classisland`:提醒落库 → 待提醒 → 回执 → 确认后不补发 | | **用户可选择通知是否在 ClassIsland 上显示** | ✅ | 插件设置页开关「接收 ClassHelper提醒」(本机总闸)+ Web 端「下发提醒」(标题 / 内容 / 时长 / 优先级 / 语音朗读 / 是否同步通知中心);`verify:classisland`:提醒落库 → 待提醒 → 回执 → 确认后不补发 |
| 能成功打包 Windows EXE | ✅ | `-x64-setup.exe` / `-x64-portable.exe` / `win-unpacked/*.exe`(见 [7. 打包与交付](#7-打包与交付)) | | 能成功打包 Windows EXE | ✅ | `-x64-setup.exe` / `-x64-portable.exe`(见 [7. 打包与交付](#7-打包与交付)) |
| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、数据库、打包与常见问题 | | 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、数据库、打包与常见问题 |
### 6.2 最近一次实测 ### 6.2 最近一次实测
@@ -1370,10 +1543,10 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
| ----------------------------------- | -------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------------- | -------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| 客户端渲染产物 `dist/renderer` | 3.2MB | **2.1MB** | 成绩页图表从 echarts 全量(1.12MB,占 35%)换成内置 HTML/SVG 组件;Element Plus 图标从全量注册 293 个改为按需 42/33 个 | | 客户端渲染产物 `dist/renderer` | 3.2MB | **2.1MB** | 成绩页图表从 echarts 全量(1.12MB,占 35%)换成内置 HTML/SVG 组件;Element Plus 图标从全量注册 293 个改为按需 42/33 个 |
| 客户端主进程产物体积 | 242KB | 242KB | 未变(冒烟代码拆分会重复 `island`/`config` 单例,esbuild 的 CJS 打包无法代码分割,故保留) | | 客户端主进程产物体积 | 242KB | 242KB | 未变(冒烟代码拆分会重复 `island`/`config` 单例,esbuild 的 CJS 打包无法代码分割,故保留) |
| 客户端 `win-unpacked` | 372MB | **323MB** | `electronLanguages: [zh-CN]`,只留中文语言包(其余 54 个 `locales/*.pak` 共 48MB) | | 客户端解包目录(中间物,打完即删) | 372MB | **323MB** | `electronLanguages: [zh-CN]`,只留中文语言包(其余 54 个 `locales/*.pak` 共 48MB) |
| 客户端 `setup.exe` / `portable.exe` | 107MB | **98MB** | 同上。**安装包有硬底**:`ClassHelper.exe`(electron.exe)压缩后本身就有 85~90MB,60MB 不可达 | | 客户端 `setup.exe` / `portable.exe` | 107MB | **98MB** | 同上。**安装包有硬底**:`ClassHelper.exe`(electron.exe)压缩后本身就有 85~90MB,60MB 不可达 |
| 服务端 `dist` | 3.8MB | **0.6MB** | `tsconfig.build.json` 关掉 `declaration`/`declarationMap`/`sourceMap`(占原体积 82%) | | 服务端 `dist` | 3.8MB | **0.6MB** | `tsconfig.build.json` 关掉 `declaration`/`declarationMap`/`sourceMap`(占原体积 82%) |
| 服务端免安装目录 | 436MB | **335MB** | 打包末尾的运行时裁剪:删掉 Prisma 为 postgresql/cockroachdb/sqlserver 准备的查询编译器副本、`*.map`、文档与测试夹具(共 2329 项 / 101MB) | | 服务端组装运行时(中间物,打完即删) | 436MB | **335MB** | 打包末尾的运行时裁剪:删掉 Prisma 为 postgresql/cockroachdb/sqlserver 准备的查询编译器副本、`*.map`、文档与测试夹具(共 2329 项 / 101MB) |
| 服务端安装程序 | 71MB | **62MB** | 同上 | | 服务端安装程序 | 71MB | **62MB** | 同上 |
| Web 端前端产物 | 3.3MB | **2.1MB** | 同样是 echarts → 内置 SVG/CSS 图表 + 图标按需 | | Web 端前端产物 | 3.3MB | **2.1MB** | 同样是 echarts → 内置 SVG/CSS 图表 + 图标按需 |
| 灵动岛主进程常驻开销 | 每 60ms 2 次系统调用 | 每 60ms 1 次 | 光标位置每跳只读一次(命中判定与"跟随鼠标屏幕"共用);窗口不可见时整跳 | | 灵动岛主进程常驻开销 | 每 60ms 2 次系统调用 | 每 60ms 1 次 | 光标位置每跳只读一次(命中判定与"跟随鼠标屏幕"共用);窗口不可见时整跳 |
@@ -1386,7 +1559,6 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
### 客户端(Windows EXE) ### 客户端(Windows EXE)
```bash ```bash
pnpm dist:dir # 免安装目录:releases/client/<版本>/免安装/ClassHelper.exe
pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS 工具,可用镜像) pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS 工具,可用镜像)
``` ```
@@ -1394,32 +1566,39 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
(`--config.electronDist`,不重复下载 150MB+);默认走国内镜像(`ELECTRON_MIRROR`、 (`--config.electronDist`,不重复下载 150MB+);默认走国内镜像(`ELECTRON_MIRROR`、
`ELECTRON_BUILDER_BINARIES_MIRROR`,可用环境变量覆盖);构建缓存固定到仓库内 `.cache/`; `ELECTRON_BUILDER_BINARIES_MIRROR`,可用环境变量覆盖);构建缓存固定到仓库内 `.cache/`;
用 `--config.directories.output` 把输出目录覆盖成 `releases/client/<版本>/`,打包成功后再把 用 `--config.directories.output` 把输出目录覆盖成 `releases/client/<版本>/`,打包成功后再把
electron-builder 的原始布局整理成 `安装包/`、`免安装/`、`构建中间/`; electron-builder 的原始布局整理成 `安装包/`、`构建中间/`,并把 `win-unpacked/`(约 323MB 的中间物)
收尾把交付物完整性标签改成 Medium(Low 标签会导致装不上 / 双击没反应,见 [8. 常见问题](#8-常见问题))。 删掉;收尾把交付物完整性标签改成 Medium(Low 标签会导致装不上 / 双击没反应,
见 [8. 常见问题](#8-常见问题))。
产物结构:`免安装/ClassHelper.exe`(Electron 运行时)+ `resources/app.asar` **只有两个交付物**,都落在 `releases/client/<版本>/安装包/`:`-x64-setup.exe`(NSIS 安装程序)
(仅含 `dist/` 与 `package.json`,运行时不需要 node_modules)。 与 `-x64-portable.exe`(自解压单文件,双击即用)。解包目录 `win-unpacked/` 只是生成这两个 exe 的
中间产物 —— 便携版是拿它自己做成的自解压包,所以它必须活到 portable 这一步之后才能删,
`tidyArtifacts()` 就排在最后。
> **把修复交付到「已安装的客户端」**:`pnpm dist:win` 只产出 `releases/client/<版本>/`。 > **把修复交付到「已安装的客户端」**:`pnpm dist:win` 只产出 `releases/client/<版本>/` 里的两个安装包。
> 若学生机上是**安装版**(例如装在 `D:\class\@classhelperdesktop-client`),只更新 release 而不同步安装目录, > 若学生机上是**安装版**(例如装在 `D:\class\@classhelperdesktop-client`),只更新 release 而不同步安装目录,
> 用户打开的还是旧 `resources/app.asar`,会出现「改了但界面不生效」。覆盖升级(用户配置在 `%APPDATA%`,不受影响): > 用户打开的还是旧 `resources/app.asar`,会出现「改了但界面不生效」。两个可行做法:
> >
> ```powershell > ```powershell
> # 1) 先退出客户端(托盘右键退出;有进程残留就精确结束 PID) > # 做法一(推荐):直接跑新安装包覆盖升级。用户配置在 %APPDATA%,不受影响。
> # 2) 用新产物覆盖安装目录(不删 Uninstall 等安装器文件,所以不加 /MIR) > # 静默:releases\client\<版本>\安装包\ClassHelper-<版本>-x64-setup.exe /S
> robocopy "packages\desktop-client\release\win-unpacked" "D:\class\@classhelperdesktop-client" /E /NFL /NDL /NJH /NJS /R:2 /W:1 > # 再实测**装好那个副本**真的带上了修复(packaged=true)
> # 3) 实测**那个副本**真的带上了修复(packaged=true)
> pnpm verify:packaged --exe "D:\class\@classhelperdesktop-client\ClassHelper.exe" > pnpm verify:packaged --exe "D:\class\@classhelperdesktop-client\ClassHelper.exe"
>
> # 做法二:要按文件覆盖(例如只换 app.asar),先让 portable 自解压出一份再拷
> # 便携版双击后会解到 %TEMP% 下的一个随机目录,从那里 robocopy 即可。
> ``` > ```
>
> 注意路径变了:`dist-win.mjs` 不再在 releases 里留解包目录,
> 所以 `packages\desktop-client\release\win-unpacked` 这个旧路径已经不存在了。
最近一次打包实测(产物不入库): 最近一次打包实测(产物不入库):
| 产物 | 大小 | 冒烟结果 | | 产物 | 大小 | 冒烟结果 |
| -------------------------------------- | ------ | -------------------------------------------------------------------- | | ------------------------------------------------- | ------ | -------------------------------------------------------------------- |
| 服务端安装程序(内置 Node + Web 端) | ~62MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 | | 服务端安装程序(内置 Node + Web 端) | ~62MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 |
| `releases/client/<版本>/免安装/ClassHelper.exe` | ~235MB | ✅ 打包版冒烟全绿,`packaged: true`,退出码 0 | | `releases/client/<版本>/安装包/…-portable.exe` | ~99MB | ✅ 打包后冒烟全绿,`packaged: true`,退出码 0 |
| `releases/client/<版本>/安装包/…-portable.exe` | ~98MB | ✅ 打包后冒烟全绿,`packaged: true`,退出码 0 | | `releases/client/<版本>/安装包/…-setup.exe` | ~99MB | ✅ 构建成功,已嵌入自定义图标 |
| `releases/client/<版本>/安装包/…-setup.exe` | ~98MB | ✅ 构建成功,已嵌入自定义图标 |
| `releases/classisland-plugin/<版本>/…cipx` | ~88KB | ✅ 全量编译通过(1 警告 0 错误),包内 DLL 哈希与 `bin/Release` 一致 | | `releases/classisland-plugin/<版本>/…cipx` | ~88KB | ✅ 全量编译通过(1 警告 0 错误),包内 DLL 哈希与 `bin/Release` 一致 |
### ClassIsland 插件 ### ClassIsland 插件
+1 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "class-helper", "name": "class-helper",
"version": "1.2.0", "version": "1.3.0",
"private": true, "private": true,
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)", "description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
"packageManager": "pnpm@11.8.0", "packageManager": "pnpm@11.8.0",
@@ -44,7 +44,6 @@
"dev:site": "pnpm docs:build && node website/serve.mjs", "dev:site": "pnpm docs:build && node website/serve.mjs",
"docs:check": "node docs/build.mjs --check", "docs:check": "node docs/build.mjs --check",
"dist:win": "pnpm --filter @classhelper/desktop-client dist:win", "dist:win": "pnpm --filter @classhelper/desktop-client dist:win",
"dist:dir": "pnpm --filter @classhelper/desktop-client dist:dir",
"dist:server": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs", "dist:server": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs",
"dist:server:linux": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs --platform linux", "dist:server:linux": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs --platform linux",
"dist:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs --cipx", "dist:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs --cipx",
+1 -1
View File
@@ -14,6 +14,6 @@ description: |-
4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。 4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll' entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
url: https://github.com/LoveChengke/ClassHelper url: https://github.com/LoveChengke/ClassHelper
version: 1.2.0.0 version: 1.3.0.0
apiVersion: 2.0.0.0 apiVersion: 2.0.0.0
author: ClassHelper author: ClassHelper
+3 -15
View File
@@ -149,25 +149,14 @@ if (!fs.existsSync(cipxFile)) {
// 归集到 releases/classisland-plugin/<版本>/(与客户端、服务端交付产物同一约定;releases/ 不入库)。 // 归集到 releases/classisland-plugin/<版本>/(与客户端、服务端交付产物同一约定;releases/ 不入库)。
// 只清理**当前版本**目录,历史版本原样保留 —— 原先这里是 rmSync 整个组件目录,重打一次就把 // 只清理**当前版本**目录,历史版本原样保留 —— 原先这里是 rmSync 整个组件目录,重打一次就把
// 上一版的留档抹掉了(插件版本是四段 <产品版本>.0,目录名取前三段与产品版本对齐)。 // 上一版的留档抹掉了(插件版本是四段 <产品版本>.0,目录名取前三段与产品版本对齐)。
// 产物只有 `安装包/*.cipx` + `checksums.md`:**.cipx 本身就是个 zip**,解压即用的那份
// 「免安装目录」与它内容完全一样,纯属重复留档,已不再产出。
const pluginVersion = manifestVersion.split('.').slice(0, 3).join('.'); const pluginVersion = manifestVersion.split('.').slice(0, 3).join('.');
const versionDir = path.join(releasesRoot, pluginVersion); const versionDir = path.join(releasesRoot, pluginVersion);
const installerDir = path.join(versionDir, '安装包'); const installerDir = path.join(versionDir, '安装包');
const portableDir = path.join(versionDir, '免安装', 'ClassHelper.ClassIslandPlugin');
fs.rmSync(installerDir, { recursive: true, force: true }); fs.rmSync(installerDir, { recursive: true, force: true });
fs.rmSync(path.dirname(portableDir), { recursive: true, force: true }); fs.rmSync(path.join(versionDir, '免安装'), { recursive: true, force: true });
fs.mkdirSync(installerDir, { recursive: true }); fs.mkdirSync(installerDir, { recursive: true });
fs.mkdirSync(portableDir, { recursive: true });
// 免安装目录:解压即用,直接放进 ClassIsland 的 Plugins 目录也能加载
for (const name of fs.readdirSync(outputDir)) {
const from = path.join(outputDir, name);
if (!fs.statSync(from).isFile()) continue;
// 依赖清单与调试符号对加载没影响,但会让老师误以为"要装一堆东西",因此只带最小集合
if (!['ClassHelper.ClassIslandPlugin.dll', 'manifest.yml', 'icon.png', 'README.md'].includes(name)) {
continue;
}
fs.copyFileSync(from, path.join(portableDir, name));
}
for (const file of fs.readdirSync(cipxDir)) { for (const file of fs.readdirSync(cipxDir)) {
// .cipx 是插件包本体,校验清单跟着版本目录走 // .cipx 是插件包本体,校验清单跟着版本目录走
@@ -177,4 +166,3 @@ for (const file of fs.readdirSync(cipxDir)) {
log(`插件包已归集到 ${path.relative(repoRoot, versionDir)}`); log(`插件包已归集到 ${path.relative(repoRoot, versionDir)}`);
for (const file of fs.readdirSync(installerDir)) log(` 安装包/${file}`); for (const file of fs.readdirSync(installerDir)) log(` 安装包/${file}`);
for (const file of fs.readdirSync(portableDir)) log(` 免安装/ClassHelper.ClassIslandPlugin/${file}`);
@@ -507,7 +507,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
/* ---------------------------------------------------------------- 内部实现 */ /* ---------------------------------------------------------------- 内部实现 */
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary> /// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
private const string PluginVersion = "1.2.0.0"; private const string PluginVersion = "1.3.0.0";
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary> /// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
private static string SafeAppVersion() private static string SafeAppVersion()
+2 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/desktop-client", "name": "@classhelper/desktop-client",
"version": "1.2.0", "version": "1.3.0",
"private": true, "private": true,
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)", "description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper", "author": "Class Helper",
@@ -14,8 +14,7 @@
"start": "electron .", "start": "electron .",
"smoke": "node scripts/smoke.mjs", "smoke": "node scripts/smoke.mjs",
"typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", "typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
"dist:win": "npm run build && node scripts/dist-win.mjs", "dist:win": "npm run build && node scripts/dist-win.mjs"
"dist:dir": "npm run build && node scripts/dist-win.mjs --dir"
}, },
"dependencies": { "dependencies": {
"@classhelper/shared": "workspace:*", "@classhelper/shared": "workspace:*",
+50 -13
View File
@@ -10,12 +10,15 @@
* *
* 用法: * 用法:
* node scripts/dist-win.mjs # nsis 安装包 + portable 单文件 * node scripts/dist-win.mjs # nsis 安装包 + portable 单文件
* node scripts/dist-win.mjs --dir # 仅生成免安装目录
* *
* 产物统一落到 releases/client/<版本>/(releases/ 不入库,与服务端、插件同级): * 产物统一落到 releases/client/<版本>/(releases/ 不入库,与服务端、插件同级):
* 安装包/ ClassHelper-<版本>-x64-setup.exe / -portable.exe(+ .blockmap) * 安装包/ ClassHelper-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
* 免安装/ electron-builder 的 win-unpacked 内容
* 构建中间/ builder-*.yml、*.nsis.7z * 构建中间/ builder-*.yml、*.nsis.7z
*
* 产物里**没有免安装目录**:electron-builder 的 `win-unpacked/` 只是生成两个 exe 的中间物
* (每版 320MB 上下),打完就删 —— 便携版 `-portable.exe` 已是自解压单文件,交付不需要解包目录。
* 因此也没有 `--dir`:那等于只产出中间物、拿不到任何交付物。需要临时跑一下解包目录时直接调
* electron-builder,别往 releases/ 里塞。
* electron-builder 自身的 `directories.output` 由本脚本用 --config 覆盖成上面的版本目录; * electron-builder 自身的 `directories.output` 由本脚本用 --config 覆盖成上面的版本目录;
* 显式传入 `-c.directories.output=...` 时不覆盖,整理(tidyArtifacts)以传入的目录为准。 * 显式传入 `-c.directories.output=...` 时不覆盖,整理(tidyArtifacts)以传入的目录为准。
*/ */
@@ -27,9 +30,7 @@ import { resolveNodeRuntime } from '../../../scripts/lib/node-runtime.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(root, '..', '..'); const repoRoot = path.resolve(root, '..', '..');
const args = process.argv.slice(2); const passthrough = process.argv.slice(2);
const dirOnly = args.includes('--dir');
const passthrough = args.filter((item) => item !== '--dir');
// makensis 会在 %TEMP% 所在位置建临时文件:Git Bash 里 TEMP=/tmp 是 MSYS 路径, // makensis 会在 %TEMP% 所在位置建临时文件:Git Bash 里 TEMP=/tmp 是 MSYS 路径,
// 原生程序会把它当成「当前盘根下的 tmp」(D:\tmp,不存在)而报 // 原生程序会把它当成「当前盘根下的 tmp」(D:\tmp,不存在)而报
@@ -74,7 +75,7 @@ const destDir = explicitOutput
: path.join(repoRoot, 'releases', 'client', clientVersion); : path.join(repoRoot, 'releases', 'client', clientVersion);
if (!explicitOutput) overrides.push(`--config.directories.output=${destDir}`); if (!explicitOutput) overrides.push(`--config.directories.output=${destDir}`);
const cliArgs = [cli, '--win', ...(dirOnly ? ['--dir'] : []), ...overrides, ...passthrough]; const cliArgs = [cli, '--win', ...overrides, ...passthrough];
console.log('[dist] electron-builder', cliArgs.slice(1).join(' ')); console.log('[dist] electron-builder', cliArgs.slice(1).join(' '));
// 必须用真正的 node 执行 electron-builder CLI: // 必须用真正的 node 执行 electron-builder CLI:
@@ -98,7 +99,17 @@ for (const name of ['win-unpacked', '免安装', '安装包', '构建中间']) {
console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`); console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`);
} }
} }
if (cleaned) console.log('[dist] 已清理上一轮的免安装目录 / 安装包 / 构建中间'); if (cleaned) console.log('[dist] 已清理上一轮的中间物与安装包');
// 打包**之前**就要把输出目录的完整性标签压回 Low(继承),不留到收尾 ——
// 实测过一次:上一轮留下的 win-unpacked 是收尾时用 icacls 标成 Medium 的,
// 而 7za 自身带 Low 标签,pack 时读不动这些 Medium 目录,报
// "cannot open file ... Access is denied",打包直接失败。
try {
execFileSync('icacls', [destDir, '/setintegritylevel', '(OI)(CI)Low'], { stdio: 'pipe', windowsHide: true });
} catch {
/* 宿主目录树本就没有 Low 标签体系时会被系统拒绝,保持继承即可 */
}
const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env }); const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env });
@@ -138,9 +149,9 @@ function normalizeIntegrityLabel(dir) {
/** /**
* 把 electron-builder 的原始输出整理成 releases 的固定分层: * 把 electron-builder 的原始输出整理成 releases 的固定分层:
* win-unpacked/ → 免安装/
* *.exe / *.blockmap → 安装包/ * *.exe / *.blockmap → 安装包/
* *.yml / *.nsis.7z → 构建中间/ * *.yml / *.nsis.7z → 构建中间/
* win-unpacked/ → **直接删掉**(见文件头:它只是生成两个 exe 的中间物)
* electron-builder 每次都会按自己的布局重写这些路径,因此整理只放在打包成功之后做; * electron-builder 每次都会按自己的布局重写这些路径,因此整理只放在打包成功之后做;
* 同名文件用 renameSync 同盘移动,不会产生大文件的额外拷贝。 * 同名文件用 renameSync 同盘移动,不会产生大文件的额外拷贝。
*/ */
@@ -152,11 +163,12 @@ function tidyArtifacts(dir) {
fs.renameSync(from, path.join(to, path.basename(from))); fs.renameSync(from, path.join(to, path.basename(from)));
}; };
const unpacked = path.join(dir, 'win-unpacked'); // 必须留在最后删:electron-builder 的 portable 目标就是拿它自己解包出来的 app 做自解压包,
const portableDir = path.join(dir, '免安装'); // 提前删会让 `-portable.exe` 这一步找不到源文件。
if (fs.existsSync(unpacked)) { const throwaway = [];
fs.rmSync(portableDir, { recursive: true, force: true }); for (const name of ['win-unpacked', '免安装']) {
fs.renameSync(unpacked, portableDir); const from = path.join(dir, name);
if (fs.existsSync(from)) throwaway.push(from);
} }
for (const name of fs.readdirSync(dir)) { for (const name of fs.readdirSync(dir)) {
@@ -165,6 +177,31 @@ function tidyArtifacts(dir) {
if (/\.(exe|blockmap)$/i.test(name)) moveInto(from, '安装包'); if (/\.(exe|blockmap)$/i.test(name)) moveInto(from, '安装包');
else if (/\.(yml|7z)$/i.test(name)) moveInto(from, '构建中间'); else if (/\.(yml|7z)$/i.test(name)) moveInto(from, '构建中间');
} }
for (const from of throwaway) {
// 体积必须在删之前量:删完再 walk 只会得到 0
const mb = Math.round(dirSizeMb(from));
try {
fs.rmSync(from, { recursive: true, force: true });
console.log(`[dist] 已删除中间物 ${path.basename(from)}/(约 ${mb} MB)`);
} catch (error) {
console.warn(`[dist] 中间物 ${path.basename(from)}/ 删除失败(不影响交付物):${error.message}`);
}
}
}
/** 交付目录里没有现成的体积统计函数,这里只在打日志时用一次 */
function dirSizeMb(target) {
let total = 0;
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else total += fs.statSync(full).size;
}
};
if (fs.existsSync(target)) walk(target);
return total / 1024 / 1024;
} }
child.on('exit', (code) => { child.on('exit', (code) => {
@@ -67,6 +67,15 @@ const isCall = computed(() => kind.value === 'call');
const isHomework = computed(() => kind.value === 'homework'); const isHomework = computed(() => kind.value === 'homework');
const queueCount = computed(() => state.value.queued.length); const queueCount = computed(() => state.value.queued.length);
/**
* 是不是"点了知道了 / 标为已读"之后的**收回动画**阶段(`reason === 'closing'`)。
*
* 这一阶段主进程已经把状态清空(`active` 为 null、`queued` 为空),形态却切回了胶囊:
* 卡片正沿既有的形变路径收回,**胶囊里不该显示任何文字** —— 否则会闪出一句
* "新消息 · 点击查看"(那是"有新消息"的语义,与"正在消失"正好相反)。
*/
const isClosing = computed(() => state.value.reason === 'closing');
/** /**
* 这一批**未处理**的消息(正在展示的 + 队列里的),按「重要程度 → 时间」排序。 * 这一批**未处理**的消息(正在展示的 + 队列里的),按「重要程度 → 时间」排序。
* 与主进程 `pendingNotifications()` 同一口径(同一份 `PRIORITY_RANK`)。 * 与主进程 `pendingNotifications()` 同一口径(同一份 `PRIORITY_RANK`)。
@@ -460,6 +469,8 @@ const islandClass = computed(() => ({
call: isCall.value, call: isCall.value,
homework: isHomework.value, homework: isHomework.value,
'after-class': showAfterClassHint.value, 'after-class': showAfterClassHint.value,
// 收回动画:胶囊里不显示内容(见 isClosing 与 .island-card.closing 的说明)
closing: isClosing.value,
})); }));
/** /**
@@ -1048,6 +1059,18 @@ body {
justify-content: flex-end; justify-content: flex-end;
} }
/**
* 收回动画(`reason === 'closing'`):胶囊收回过程中**不显示任何胶囊内容**。
*
* 这时状态已经在同一帧清空(没有可展示的消息),若照常渲染胶囊层,卡片刚收回就会闪出
* 一句"新消息 · 点击查看" —— 语义正好相反。这里保留 DOM(宽度固定为 `--island-w`,
* 内容盒不会重排,符合"开合时胶囊内容不许重排"的约定)只把内容隐藏,
* 卡片形状照样沿既有弹簧收回,随后由主进程淡出窗口。
*/
.island-card.closing .pill-inner {
opacity: 0;
}
.pill-icon { .pill-icon {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -0,0 +1,304 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { BrowserWindow, app } from 'electron';
import { CLIENT_UPDATE_IDLE_MS, type ClientUpdateInfoDto } from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js';
import { island } from './island.js';
import { logger } from './logger.js';
/**
* 服务端统一下发的自动更新(下载 + 空闲时静默安装)。
*
* 设计要点(都是"教室机器"这个使用场景逼出来的):
* 1. **下载随时进行、安装只在完全空闲时**:教室机的带宽与注意力都有限,上课期间重启应用是灾难,
* 所以安装的三个必要条件缺一不可 —— 不在上课、主窗口已隐藏满 `CLIENT_UPDATE_IDLE_MS`(5 分钟)、
* 本进程还没装过;
* 2. **下完先校验 sha256**:服务端给的哈希对不上就删掉文件、记 error,**不做任何信任放行**
* (宁可这次更新失败,也不能把一个来源不明的 exe 装到教室里);
* 3. **安装脚本脱离本进程**:`start /wait` 等安装器跑完后重新拉起应用,
* 本进程随即走既有退出路径退出(重启应用这件事交给安装脚本,而不是自己 `relaunch`,
* 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。
*/
/** 单个安装包的下载超时:教室机网络慢,给足 10 分钟 */
const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
/** 下载文件名的合法性(服务端给的 fileName 会拼进本地路径,必须挡住路径穿越) */
const SAFE_FILE_NAME = /^[A-Za-z0-9._-]+$/;
/** 待安装更新的目录:`<userData>/updates`(放在 userData 下,随配置一起被卸载/重置清掉) */
export function pendingUpdateDir(): string {
return path.join(app.getPath('userData'), 'updates');
}
/** 主窗口"已隐藏多久"的起点(null = 窗口当前可见或还没建) */
let mainWindowHiddenAt: number | null = null;
/** 本进程是否已经发起过安装(防止心跳与 hide 事件重复触发) */
let installing = false;
/**
* 退出本进程的回调(由 index.ts 注册成既有的 `quitApp()`)。
*
* 为什么用注册而不是直接 `import { quitApp } from './index.js'`:那会形成
* index.ts ⇄ auto-update.ts 的循环导入,ESM 下 `quitApp` 的绑定在求值顺序变化时会变成未初始化引用。
*/
let quitHandler: (() => void) | null = null;
/** 注册"安装前退出本进程"的回调(index.ts 启动时调用一次) */
export function setQuitHandler(handler: () => void): void {
quitHandler = handler;
}
/** 正在下载的版本(同一版本不并发下载) */
const downloading = new Set<string>();
/** 这次运行的主窗口(安装判定要看它可见性;窗口重建后重新挂一遍) */
let hookedWindow: BrowserWindow | null = null;
/**
* 挂主窗口的显示/隐藏监听(index.ts 在 `createWindow()` 之后调用)。
*
* 为什么要"记时间戳"而不是每次现算:`BrowserWindow` 没有"隐藏了多久"这种查询,
* 而「窗口隐藏满 5 分钟」是本功能的核心判据,只能在 hide/show 事件上自己打点。
*/
export function watchMainWindow(win: BrowserWindow): void {
if (hookedWindow === win) return;
hookedWindow = win;
win.on('hide', () => {
if (mainWindowHiddenAt === null) mainWindowHiddenAt = Date.now();
// 刚隐藏正是最合适的安装时机(人已经走了),立即判一次
void maybeInstallPendingUpdate();
});
win.on('show', () => {
mainWindowHiddenAt = null;
});
win.on('closed', () => {
mainWindowHiddenAt = null;
if (hookedWindow === win) hookedWindow = null;
});
if (win.isVisible()) mainWindowHiddenAt = null;
}
/** 主窗口是否已经隐藏满 `CLIENT_UPDATE_IDLE_MS` */
function mainWindowIdleLongEnough(): boolean {
if (mainWindowHiddenAt === null) return false;
return Date.now() - mainWindowHiddenAt >= CLIENT_UPDATE_IDLE_MS;
}
/** 主窗口是否可见(空闲判定的一部分:可见 ⇒ 有人在用,绝不重启) */
function mainWindowVisible(): boolean {
if (hookedWindow && !hookedWindow.isDestroyed()) return hookedWindow.isVisible();
return BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isVisible());
}
/** 服务端是否要求升级 / 本机是否确实更旧 */
export function shouldConsider(version: string): boolean {
return getConfig().autoUpdate !== false && !!version.trim();
}
/**
* 处理一次服务端下发的更新信息:该下就下、下好了就写进待安装配置。
*
* - `autoUpdate === false`:用户/管理员关掉了自动更新,直接返回(设置页会显示服务端的目标版本,由人工处理);
* - 同一版本已经下载过(`pendingUpdateVersion` 命中且文件还在):跳过下载,直接尝试安装。
*/
export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: string): Promise<void> {
const version = targetVersion.trim() || info.version.trim();
if (!shouldConsider(version)) {
logger.info(`忽略服务端下发的更新 ${version || '(未指定版本)'}:自动更新已在设置中关闭`);
return;
}
if (downloading.has(version)) {
logger.debug(`更新 ${version} 正在下载中,跳过重复请求`);
return;
}
const config = getConfig();
if (config.pendingUpdateVersion === version) {
const existing = config.pendingUpdateFile;
if (existing && fs.existsSync(existing)) {
logger.info(`更新 ${version} 早已下载完成,直接尝试安装`);
await maybeInstallPendingUpdate();
return;
}
logger.warn(`更新 ${version} 的安装包已不在磁盘上(${existing || '未记录路径'}),重新下载`);
}
if (!SAFE_FILE_NAME.test(info.fileName)) {
logger.error(`拒绝下载更新:服务端给的文件名不合法(${info.fileName})`);
return;
}
const dir = pendingUpdateDir();
const target = path.join(dir, info.fileName);
const temp = `${target}.part`;
const token = config.token?.trim() ?? '';
if (!token) {
logger.warn(`暂不下载更新 ${version}:尚未登录(没有可用的下载令牌)`);
return;
}
downloading.add(version);
const startedAt = Date.now();
try {
fs.mkdirSync(dir, { recursive: true });
// 先删掉上次中断留下的临时文件
await fsp.rm(temp, { force: true });
const url = `${config.serverUrl}${info.url}`;
logger.info(`开始下载更新 ${version}(${info.fileName},${formatSize(info.size)})`);
const response = await fetch(url, {
headers: { authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`下载失败:HTTP ${response.status}`);
}
if (!response.body) {
throw new Error('下载失败:响应没有内容');
}
// 流式写盘:教室机的安装包几十 MB,不能整个读进内存
const handle = await fsp.open(temp, 'w');
try {
for await (const chunk of response.body as unknown as AsyncIterable<Uint8Array>) {
await handle.write(chunk);
}
} finally {
await handle.close();
}
const digest = await sha256File(temp);
const expected = info.sha256.trim().toLowerCase();
if (!expected) {
// 服务端没给哈希就没法校验:宁可不装,也不留一个来源不明的安装包在磁盘上
await fsp.rm(temp, { force: true });
logger.error(`更新 ${version} 的安装包没有提供 sha256,已删除(不做信任放行)`);
return;
}
if (digest !== expected) {
await fsp.rm(temp, { force: true });
logger.error(
`更新 ${version} 的安装包校验失败(sha256 不符),已删除:期望 ${expected},实际 ${digest}`,
);
return;
}
await fsp.rm(target, { force: true });
await fsp.rename(temp, target);
saveConfig({ pendingUpdateVersion: version, pendingUpdateFile: target });
logger.info(
`更新 ${version} 已下载并校验通过(${formatSize(fs.statSync(target).size)},耗时 ${Math.round(
(Date.now() - startedAt) / 1000,
)} 秒),将在空闲时自动安装`,
);
await maybeInstallPendingUpdate();
} catch (error) {
await fsp.rm(temp, { force: true }).catch(() => undefined);
logger.error(`下载更新 ${version} 失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
downloading.delete(version);
}
}
/** 文件的 sha256(十六进制小写) */
async function sha256File(file: string): Promise<string> {
const hash = crypto.createHash('sha256');
const handle = await fsp.open(file, 'r');
try {
const buffer = Buffer.alloc(1024 * 256);
for (;;) {
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
if (bytesRead <= 0) break;
hash.update(buffer.subarray(0, bytesRead));
}
} finally {
await handle.close();
}
return hash.digest('hex');
}
/** 人类可读的字节数(日志与界面提示用) */
function formatSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '大小未知';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/**
* 如果条件全部满足就装更新。
*
* 条件(缺一不可):非冒烟模式 / 配置里有待安装版本 / 文件还在 / 不在上课 /
* 主窗口已隐藏满 5 分钟 / 本进程没装过。**每次心跳后与主窗口 hide 后都会调用**,
* 所以"老师下课关掉窗口 → 5 分钟后自动装上"就是靠这里触发的。
*/
export async function maybeInstallPendingUpdate(): Promise<void> {
if (installing) return;
if (process.env.ELECTRON_SMOKE_TEST === '1') return;
const config = getConfig();
const version = config.pendingUpdateVersion?.trim() ?? '';
const installer = config.pendingUpdateFile?.trim() ?? '';
if (!version || !installer) return;
if (!fs.existsSync(installer)) {
logger.warn(`待安装的更新 ${version} 已不存在(${installer}),清除待安装记录`);
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
return;
}
if (island.getState().inClass) {
logger.info(`更新 ${version} 已就绪,但正在上课,暂不安装`);
return;
}
if (mainWindowVisible()) {
logger.debug(`更新 ${version} 已就绪,但主窗口可见(人正在用),暂不安装`);
return;
}
if (!mainWindowIdleLongEnough()) {
const waited = mainWindowHiddenAt === null ? 0 : Math.round((Date.now() - mainWindowHiddenAt) / 1000);
logger.debug(
`更新 ${version} 已就绪,但主窗口隐藏仅 ${waited} 秒` +
`(需满 ${Math.round(CLIENT_UPDATE_IDLE_MS / 1000)} 秒),暂不安装`,
);
return;
}
if (process.platform !== 'win32') {
// 本项目客户端只发 Windows 版;其它平台(开发机上跑 Linux/macOS)只记日志
logger.warn(
`更新 ${version} 已就绪,但当前平台(${process.platform})不支持静默安装,请手动运行:${installer}`,
);
return;
}
installing = true;
logger.info(`开始静默安装更新 ${version}:${installer}`);
try {
const exe = app.getPath('exe');
const command = `start "" /wait "${installer}" /S && start "" "${exe}"`;
const child = spawn('cmd.exe', ['/c', command], {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
child.unref();
// 安装脚本会等安装器结束后重新拉起应用;本进程随即退出(复用既有退出路径,保证配置已落盘)
if (quitHandler) {
quitHandler();
} else {
logger.warn('没有注册退出回调,改为自行退出(配置会在退出前同步落盘)');
app.quit();
}
} catch (error) {
installing = false;
logger.error(
`启动更新安装失败(将在下次空闲时重试):${error instanceof Error ? error.message : String(error)}`,
);
}
}
/** 仅供冒烟/自检使用:当前是否处于"窗口已隐藏满 5 分钟"状态 */
export function isMainWindowIdleLongEnough(): boolean {
return mainWindowIdleLongEnough();
}
@@ -0,0 +1,33 @@
/**
* IPC 通道名集中管理(主进程与 preload 共用同一份字符串)。
*
* **为什么单独一个文件、而且这里一个 import 都不能有**:preload 需要这些字符串,
* 但 preload 在 `sandbox: true` 下运行 —— 沙箱化 preload **不能 require `node:fs` /
* `node:path` / `node:crypto` 这类内建模块**。而 esbuild 会把 preload 的**运行时 import**
* 整条依赖链打进同一个产物:preload 一旦 `import { IPC_CHANNELS } from './ipc.js'`,
* 就会把 `ipc.js` → `client-agent.js` / `config.js` / `logger.js` / `auto-update.js`
* 一起拉进来(它们都要 `node:fs`),于是 preload 在沙箱里**加载即失败**,
* 表现是渲染进程里 `window.desktop === undefined`(冒烟断言「preload contextBridge 注入」直接红,
* 后续所有 `window.desktop.*` 调用抛 `Cannot read properties of undefined`)。
*
* 所以这个文件是**叶子模块**:只有字符串常量,没有任何 import、没有任何副作用。
* 新增通道时加在这里,`main/ipc.ts` 与 `preload/index.ts` 各自 import 它。
*/
export const IPC_CHANNELS = {
getConfig: 'classhelper:config:get',
saveConfig: 'classhelper:config:save',
clearConfig: 'classhelper:config:clear',
appInfo: 'classhelper:app:info',
openExternal: 'classhelper:shell:open-external',
updateCheck: 'classhelper:update:check',
updateIgnore: 'classhelper:update:ignore',
getAutoLaunch: 'classhelper:app:get-autolaunch',
setAutoLaunch: 'classhelper:app:set-autolaunch',
openLogDir: 'classhelper:app:open-log-dir',
openDevTools: 'classhelper:app:open-devtools',
clientStatus: 'classhelper:client:status',
clientBeatNow: 'classhelper:client:beat-now',
} as const;
/** 主进程 → 渲染进程的单向推送:有可用更新(同理,供 preload 与主进程共用) */
export const UPDATE_AVAILABLE_CHANNEL = 'classhelper:update:available';
@@ -0,0 +1,414 @@
import { spawn } from 'node:child_process';
import os from 'node:os';
import { BrowserWindow, app, dialog } from 'electron';
import {
CLIENT_COMMAND_LABELS,
CLIENT_HEARTBEAT_INTERVAL_MS,
isNewerVersion,
type ClientCommandDto,
type ClientCommandKind,
type ClientHeartbeatRequest,
type ClientHeartbeatResponse,
type ClientUpdateInfoDto,
} from '@classhelper/shared';
import { maybeInstallPendingUpdate, considerUpdate } from './auto-update.js';
import { getConfig } from './config.js';
import { island } from './island.js';
import { logger } from './logger.js';
/**
* 桌面客户端的服务端联动代理(心跳上报)。
*
* **为什么是"客户端定时拉"而不是服务端推**:教室机的连接经常断(代理、休眠、重启),
* 而「这台机器是什么版本、要不要更新、有没有待执行命令」必须下次上线就能补上 —— 所以统一用
* 「服务端记状态 + 客户端定时上报」的范式(与 ClassIsland 插件的上报同一套路),不引入新的长连接握手。
* 每次上报的响应里同时带回「服务端指定的目标版本 + 待执行命令」。
*
* 三条边界:
* 1. **没有 token 就不上报**:未登录时静默跳过(登录后下一次自然就上报了),
* 不能拿空令牌去打服务端,否则日志里会刷满 401;
* 2. **401/403 退避到 5 分钟**:令牌可能已失效(班级密码被改 / 令牌过期),
* 继续 30 秒一次只是白刷服务端与日志;
* 3. **冒烟模式默认不上报**:每次冒烟都在服务端库里造一条设备记录没有意义,
* 只有显式 `ELECTRON_SMOKE_CLIENT_AGENT=1` 时才照常跑(给需要验证这条链路的用例用)。
*/
/** 启动后第一次上报的延迟:等窗口、登录流程与灵动岛都就绪,也不跟首屏抢带宽 */
const FIRST_BEAT_DELAY_MS = 3_000;
/** 令牌失效(401/403)后的退避间隔:5 分钟一次,直到用户重新登录 */
const UNAUTHORIZED_BACKOFF_MS = 5 * 60_000;
/** 单次上报的超时:教室机网络慢,但也不能挂着不放 */
const HEARTBEAT_TIMEOUT_MS = 10_000;
/**
* 远控命令的「本机二次确认」等待时长:60 秒内没人点确认就按取消处理。
*
* 为什么必须有这个超时:管理员在办公室下发关机,而教室里可能已经没人了 ——
* 一个永远挂着等确认的对话框会让这条命令一直"待执行",既没执行也看不出失败。
*/
const COMMAND_CONFIRM_TIMEOUT_MS = 60_000;
/**
* 「在线」判定窗口:与 shared 的 `CLIENT_ONLINE_WINDOW_MS`(90 秒)同口径 ——
* 连续丢两次心跳(30 秒 × 3)才算离线。这里不复用那个常量是因为它属于**服务端**的判定口径,
* 客户端展示的是"我这边的上报还新不新",单独留一个本地常量更容易看出两边的关系。
*/
const ONLINE_WINDOW_MS = 90_000;
/** 对外暴露的联动状态(设置页「服务端联动状态」与冒烟断言用) */
export interface ClientAgentStatus {
/** 设备标识尾号(服务端设备列表里显示的那串用来对账,完整 UUID 不上界面) */
deviceKeyTail: string;
/** 最近一次上报成功的时刻(ISO 字符串;从未上报过为 null) */
lastHeartbeatAt: string | null;
/** 最近一次失败的原因(空串 = 最近一次是成功的) */
lastError: string;
/** 已下载待安装的版本(空串 = 无) */
pendingUpdateVersion: string;
/** 服务端最近一次要求升到的版本(空串 = 没要求) */
updateTargetVersion: string;
/** 是否接受服务端统一下发的自动更新(配置值,供设置页回显) */
autoUpdate: boolean;
/** 最近 90 秒内上报成功过(与服务端的在线口径一致) */
online: boolean;
}
interface AgentState {
running: boolean;
/** 当前心跳定时器(退避期间与正常节奏共用它) */
timer: ReturnType<typeof setTimeout> | null;
/** 正在上报:避免手动「立即上报」与定时器撞在一起 */
beating: boolean;
lastHeartbeatAt: string | null;
lastError: string;
updateTargetVersion: string;
}
const state: AgentState = {
running: false,
timer: null,
beating: false,
lastHeartbeatAt: null,
lastError: '',
updateTargetVersion: '',
};
/** 设备标识尾号:取后 8 位(足够对账,又不至于把完整 UUID 贴到界面上) */
export function deviceKeyTail(deviceKey: string): string {
const trimmed = deviceKey.trim();
if (!trimmed) return '';
return trimmed.length > 8 ? trimmed.slice(-8) : trimmed;
}
/** 冒烟模式且没有显式打开联动开关时,一律不上报(避免每次冒烟都在库里造设备记录) */
function isAgentSuppressed(): boolean {
return process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_CLIENT_AGENT !== '1';
}
/**
* 上报里的"这台机器现在在干什么"。
*
* 取值就三种(服务端设备列表直接展示这段文字):
* - `上课中` —— 灵动岛按渲染进程上报的课表状态判定正在上课(此时绝不安装更新);
* - `使用中` —— 主窗口可见,人正在用;
* - `待机` —— 窗口隐藏(最小化到托盘),机器空闲。
*/
function currentStateNote(): string {
if (island.getState().inClass) return '上课中';
const win = BrowserWindow.getAllWindows().find((item) => !item.isDestroyed());
return win?.isVisible() ? '使用中' : '待机';
}
/** 已下载待安装的版本;没有则 null(服务端把它记进设备的 `pendingVersion`) */
function pendingVersionOrNull(): string | null {
const pending = getConfig().pendingUpdateVersion.trim();
return pending ? pending : null;
}
/** 清掉待执行的心跳定时器(幂等) */
function clearTimer(): void {
if (state.timer !== null) {
clearTimeout(state.timer);
state.timer = null;
}
}
/** 排下一次上报;`delayMs` 不传时用正常节奏 */
function scheduleNext(delayMs: number = CLIENT_HEARTBEAT_INTERVAL_MS): void {
if (!state.running) return;
clearTimer();
state.timer = setTimeout(() => {
state.timer = null;
void beat();
}, delayMs);
}
/**
* 一次上报(含响应处理)。
*
* 失败一律**不抛**:网络抖动是教室机的常态,只记一行日志并按原节奏重试;
* 只有 401/403 才退避(见文件头说明)。
*/
async function beat(): Promise<void> {
if (state.beating) return;
if (isAgentSuppressed()) {
// 冒烟模式:照原节奏空转(不碰服务端,也不在库里造设备记录)
state.lastError = '';
scheduleNext();
return;
}
const config = getConfig();
const token = config.token?.trim() ?? '';
if (!token) {
// 未登录:不打服务端、不记错误,等下次心跳(登录后自然会带上令牌)
logger.debug('客户端上报:尚未登录(没有令牌),本次跳过');
scheduleNext();
return;
}
state.beating = true;
let backoff = false;
try {
const body: ClientHeartbeatRequest = {
deviceKey: config.deviceKey,
name: os.hostname(),
clientVersion: app.getVersion(),
platform: `${process.platform}-${process.arch}`,
pendingVersion: pendingVersionOrNull(),
stateNote: currentStateNote(),
};
const response = await fetch(`${config.serverUrl}/api/clients/heartbeat`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS),
});
if (response.status === 401 || response.status === 403) {
// 令牌失效:退避到 5 分钟一次,直到用户重新登录
backoff = true;
state.lastError = `上报被拒绝(HTTP ${response.status}),可能登录已过期,请重新登录`;
logger.warn(`客户端上报:服务端返回 HTTP ${response.status},退避为 5 分钟一次`);
return;
}
if (!response.ok) {
state.lastError = `服务端返回 HTTP ${response.status}`;
logger.warn(`客户端上报失败:HTTP ${response.status}(${HEARTBEAT_INTERVAL_TEXT})`);
return;
}
const payload = (await response.json()) as ClientHeartbeatResponse;
state.lastHeartbeatAt = new Date().toISOString();
state.lastError = '';
state.updateTargetVersion = payload.updateTargetVersion?.trim() ?? '';
logger.debug(
`客户端上报成功(设备尾号 ${deviceKeyTail(config.deviceKey)},状态 ${body.stateNote ?? '-'}` +
`${state.updateTargetVersion ? `,服务端要求升到 ${state.updateTargetVersion}` : ''})`,
);
void handleCommand(payload.command);
await handleUpdate(payload.update, payload.updateTargetVersion);
} catch (error) {
// 网络错误 / 超时 / JSON 解析失败:按原节奏重试
state.lastError = error instanceof Error ? error.message : String(error);
logger.warn(`客户端上报失败(将于 ${HEARTBEAT_INTERVAL_TEXT} 后重试):${state.lastError}`);
} finally {
state.beating = false;
// 每次心跳(无论成败)都顺手看一下有没有下载好的更新该装了 —— 上课/窗口可见时会自动跳过
void maybeInstallPendingUpdate();
scheduleNext(backoff ? UNAUTHORIZED_BACKOFF_MS : CLIENT_HEARTBEAT_INTERVAL_MS);
}
}
const HEARTBEAT_INTERVAL_TEXT = `${Math.round(CLIENT_HEARTBEAT_INTERVAL_MS / 1000)} 秒`;
/**
* 服务端下发的远控命令(关机 / 重启 / 注销 / 锁屏)。
*
* 两道护栏缺一不可(用户要求「必须二次确认」):
* 1. **本机二次确认**:弹一个默认选中「取消」的对话框,60 秒不确认按取消处理 ——
* 服务端那边虽然也确认过,但真正会被关掉的机器上必须有人当场知道;
* 2. **同一条命令只弹一次**:它随每次心跳重复下发,用内存集合去重,避免 30 秒弹一个框。
*
* 执行后**必须回执**(`POST /api/clients/commands/:id/ack`,body 带 deviceKey):
* 否则服务端那条命令会一直挂在"待执行"里,Web 端看不到结果。回执失败时把结果暂存,
* 下一次心跳只重试回执、不重复弹窗。
*/
const inFlightCommands = new Set<string>();
const unackedResults = new Map<string, { ok: boolean; result: string }>();
async function handleCommand(command: ClientCommandDto | null): Promise<void> {
if (!command) return;
const label = CLIENT_COMMAND_LABELS[command.kind] ?? command.kind;
const expired =
command.expiresAt !== null && Number.isFinite(Date.parse(command.expiresAt))
? Date.parse(command.expiresAt) < Date.now()
: false;
if (expired) {
logger.warn(`收到已过期的远控命令(${label}),已忽略:${command.payload || '无备注'}`);
return;
}
// 处理过但回执没送达:只重试回执
const pending = unackedResults.get(command.id);
if (pending) {
await ackCommand(command.id, pending.ok, pending.result);
return;
}
// 正在等本机确认:不要再弹第二个框
if (inFlightCommands.has(command.id)) return;
inFlightCommands.add(command.id);
try {
const confirmed = await confirmCommand(label, command.payload);
if (!confirmed) {
const result = '本机用户取消(或 60 秒未确认)';
logger.warn(`远控命令「${label}」未执行:${result}`);
await ackCommand(command.id, false, result);
return;
}
const outcome = await executeCommand(command.kind);
logger.warn(`远控命令「${label}」执行${outcome.ok ? '成功' : '失败'}:${outcome.result}`);
await ackCommand(command.id, outcome.ok, outcome.result);
} finally {
inFlightCommands.delete(command.id);
}
}
/** 本机二次确认对话框:默认「取消」,60 秒未操作按取消处理 */
async function confirmCommand(label: string, note: string): Promise<boolean> {
const answered = dialog
.showMessageBox({
type: 'warning',
title: '来自管理员的远程指令',
message: `管理员要求本机「${label}」`,
detail: `${note ? `备注:${note}\n` : ''}确认后本机将立即${label};60 秒内未确认视为取消。`,
buttons: ['取消', '确认执行'],
defaultId: 0,
cancelId: 0,
noLink: true,
})
.then((result) => result.response === 1)
.catch(() => false);
return Promise.race([
answered,
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), COMMAND_CONFIRM_TIMEOUT_MS)),
]);
}
/** 执行系统命令:只支持 Windows(本项目客户端只发 Windows 版) */
async function executeCommand(kind: ClientCommandKind): Promise<{ ok: boolean; result: string }> {
if (process.platform !== 'win32') return { ok: false, result: '仅支持 Windows' };
const commands: Record<ClientCommandKind, { file: string; args: string[] }> = {
shutdown: { file: 'shutdown.exe', args: ['/s', '/t', '0'] },
restart: { file: 'shutdown.exe', args: ['/r', '/t', '0'] },
logoff: { file: 'shutdown.exe', args: ['/l'] },
lock: { file: 'rundll32.exe', args: ['user32.dll,LockWorkStation'] },
};
const plan = commands[kind];
try {
// detached + unref:不能等它退出(关机/注销会把本进程一起带走)
spawn(plan.file, plan.args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
return { ok: true, result: '已下达系统命令' };
} catch (error) {
return { ok: false, result: error instanceof Error ? error.message : String(error) };
}
}
/** 回执:服务端用 body 里的 deviceKey 校验"这条命令是发给这台机器的" */
async function ackCommand(commandId: string, ok: boolean, result: string): Promise<void> {
const config = getConfig();
if (!config.token) return;
try {
const response = await fetch(`${config.serverUrl}/api/clients/commands/${commandId}/ack`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${config.token}` },
body: JSON.stringify({ deviceKey: config.deviceKey, ok, result }),
signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS),
});
if (!response.ok) {
logger.warn(`远控命令回执失败(HTTP ${response.status}),将在下次心跳重试`);
unackedResults.set(commandId, { ok, result });
return;
}
unackedResults.delete(commandId);
logger.info(`远控命令回执已送达(${ok ? '成功' : '失败'}):${result}`);
} catch (error) {
logger.warn(
`远控命令回执失败(${error instanceof Error ? error.message : String(error)}),将在下次心跳重试`,
);
unackedResults.set(commandId, { ok, result });
}
}
/** 响应里的更新信息:服务端指定了目标版本、且该版本确实比本机新,才交给 auto-update */
async function handleUpdate(info: ClientUpdateInfoDto | null, targetVersion: string | null): Promise<void> {
const target = targetVersion?.trim() ?? '';
if (!target) return;
if (!info) {
// 服务端指定了目标版本但还没有这份安装包:只记一行,客户端只能提示手动更新
logger.warn(`服务端要求升到 ${target},但服务端还没有这个版本的安装包(无法自动下载)`);
return;
}
if (!isNewerVersion(app.getVersion(), target)) return;
await considerUpdate(info, target);
}
/** 启动心跳(重复调用无副作用) */
export function startClientAgent(): void {
if (state.running) return;
state.running = true;
logger.info(
isAgentSuppressed()
? '客户端上报代理:冒烟模式下不上报(需要时设 ELECTRON_SMOKE_CLIENT_AGENT=1)'
: `客户端上报代理已启动:${FIRST_BEAT_DELAY_MS / 1000} 秒后首次上报,之后每 ${HEARTBEAT_INTERVAL_TEXT} 一次`,
);
scheduleNext(FIRST_BEAT_DELAY_MS);
}
/** 停止心跳(退出清理调用;幂等) */
export function stopClientAgent(): void {
if (!state.running) return;
state.running = false;
clearTimer();
logger.info('客户端上报代理已停止');
}
/**
* 手动触发一次上报并返回最新状态(设置页「立即上报」按钮)。
* 返回的是**报告之后**的状态,界面直接照它刷新即可。
*/
export async function beatNow(): Promise<ClientAgentStatus> {
if (!state.running) startClientAgent();
if (isAgentSuppressed()) {
logger.info('客户端上报:冒烟模式下跳过手动上报');
return getClientAgentStatus();
}
clearTimer();
await beat();
return getClientAgentStatus();
}
/** 当前联动状态(纯数据,可直接跨 contextBridge 传给渲染进程) */
export function getClientAgentStatus(): ClientAgentStatus {
const config = getConfig();
const lastAt = state.lastHeartbeatAt ? Date.parse(state.lastHeartbeatAt) : Number.NaN;
return {
deviceKeyTail: deviceKeyTail(config.deviceKey),
lastHeartbeatAt: state.lastHeartbeatAt,
lastError: state.lastError,
pendingUpdateVersion: config.pendingUpdateVersion ?? '',
updateTargetVersion: state.updateTargetVersion,
autoUpdate: config.autoUpdate !== false,
online: Number.isFinite(lastAt) && Date.now() - lastAt < ONLINE_WINDOW_MS,
};
}
+118 -2
View File
@@ -1,3 +1,4 @@
import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { app, safeStorage } from 'electron'; import { app, safeStorage } from 'electron';
@@ -44,6 +45,20 @@ interface PersistedConfig {
sidebarCollapsed?: boolean; sidebarCollapsed?: boolean;
/** 用户选择「忽略此版本」的版本号(向后兼容:旧配置没有该字段视为没忽略过任何版本) */ /** 用户选择「忽略此版本」的版本号(向后兼容:旧配置没有该字段视为没忽略过任何版本) */
ignoredUpdateVersion?: string; ignoredUpdateVersion?: string;
/** 本机设备标识(向后兼容:旧配置没有该字段时首次读取生成并落盘,之后永不变) */
deviceKey?: string;
/** 是否开机自启(向后兼容:旧配置没有该字段视为关闭 —— 由用户在设置页自己打开) */
autoLaunch?: boolean;
/** 是否接受服务端统一下发的自动更新(向后兼容:旧配置没有该字段视为开启) */
autoUpdate?: boolean;
/** 开发者模式:日志调到 debug 级 + 设置里多出「开发者」页(向后兼容:旧配置没有该字段视为关闭) */
developerMode?: boolean;
/** 关怀模式:整体放大正文与控件字号,供视力不佳的用户使用(向后兼容:旧配置没有该字段视为关闭) */
careMode?: boolean;
/** 已下载待安装的更新版本(空串 = 无;跨重启保留) */
pendingUpdateVersion?: string;
/** 已下载待安装的安装包绝对路径(空串 = 无;跨重启保留) */
pendingUpdateFile?: string;
} }
const DEFAULT_CONFIG: PersistedConfig = { const DEFAULT_CONFIG: PersistedConfig = {
@@ -59,6 +74,13 @@ const DEFAULT_CONFIG: PersistedConfig = {
theme: 'light', theme: 'light',
sidebarCollapsed: false, sidebarCollapsed: false,
ignoredUpdateVersion: '', ignoredUpdateVersion: '',
deviceKey: '',
autoLaunch: false,
autoUpdate: true,
developerMode: false,
careMode: false,
pendingUpdateVersion: '',
pendingUpdateFile: '',
}; };
/** 主题白名单:非法值一律回落浅色 */ /** 主题白名单:非法值一律回落浅色 */
@@ -203,6 +225,15 @@ function readPersistedFromDisk(): PersistedConfig {
sidebarCollapsed: parsed.sidebarCollapsed === true, sidebarCollapsed: parsed.sidebarCollapsed === true,
ignoredUpdateVersion: ignoredUpdateVersion:
typeof parsed.ignoredUpdateVersion === 'string' ? parsed.ignoredUpdateVersion : '', typeof parsed.ignoredUpdateVersion === 'string' ? parsed.ignoredUpdateVersion : '',
deviceKey: typeof parsed.deviceKey === 'string' ? parsed.deviceKey : '',
autoLaunch: parsed.autoLaunch === true,
// 默认开启:老配置文件里没有这个字段时按"接受服务端统一下发的更新"处理
autoUpdate: parsed.autoUpdate !== false,
developerMode: parsed.developerMode === true,
careMode: parsed.careMode === true,
pendingUpdateVersion:
typeof parsed.pendingUpdateVersion === 'string' ? parsed.pendingUpdateVersion : '',
pendingUpdateFile: typeof parsed.pendingUpdateFile === 'string' ? parsed.pendingUpdateFile : '',
}; };
} catch { } catch {
return { ...DEFAULT_CONFIG }; return { ...DEFAULT_CONFIG };
@@ -356,9 +387,52 @@ function toDesktopConfig(state: ConfigCache): DesktopStoredConfig {
theme: normalizeTheme(persisted.theme), theme: normalizeTheme(persisted.theme),
sidebarCollapsed: persisted.sidebarCollapsed === true, sidebarCollapsed: persisted.sidebarCollapsed === true,
ignoredUpdateVersion: persisted.ignoredUpdateVersion ?? '', ignoredUpdateVersion: persisted.ignoredUpdateVersion ?? '',
deviceKey: ensureDeviceKey(persisted),
autoLaunch: persisted.autoLaunch === true,
autoUpdate: persisted.autoUpdate !== false,
developerMode: persisted.developerMode === true,
careMode: persisted.careMode === true,
pendingUpdateVersion: persisted.pendingUpdateVersion ?? '',
pendingUpdateFile: persisted.pendingUpdateFile ?? '',
}; };
} }
/**
* `crypto.randomUUID()`:Node 18+ / Chromium 里都有,但**不能假设它一定存在**
* (打包后的运行时或某些受限环境里 `crypto.randomUUID` 为 undefined,直接调用会抛)。
* 兜底用时间戳 + 随机字节拼一个同形状的 UUID v4。
*/
function generateDeviceKey(): string {
try {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
} catch {
// 落到下面的兜底
}
const hex = crypto.randomBytes(16).toString('hex');
return (
`${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(12, 15)}` +
`-a${hex.slice(15, 18)}-${hex.slice(18, 30)}`
);
}
/**
* 设备标识:**首次读取时生成并落盘,之后永不变**(它是这台机器在服务端的身份,
* 服务端按 `deviceKey` upsert 设备记录,换了它就等于换了一台机器)。
*
* 为什么放在这里生成:`getConfig()` 是所有读取路径的唯一出口(主进程各处 + 渲染进程 IPC),
* 且此时必然已经过了 `app.setPath('userData', …)`(见 `loadCache()` 的懒初始化说明),
* 写的就是当前 profile 的 config.json。落盘走既有的 120ms 合并窗口,退出时 `flushConfigSync()` 兜底。
*/
function ensureDeviceKey(persisted: PersistedConfig): string {
const existing = typeof persisted.deviceKey === 'string' ? persisted.deviceKey.trim() : '';
if (existing) return existing;
const created = generateDeviceKey();
persisted.deviceKey = created;
scheduleWrite(persisted);
logger.info(`已为本机生成设备标识(服务端据此识别这台电脑):${created}`);
return created;
}
/** 读取配置(token 已解密),供渲染进程使用 */ /** 读取配置(token 已解密),供渲染进程使用 */
export function getConfig(): DesktopStoredConfig { export function getConfig(): DesktopStoredConfig {
return toDesktopConfig(loadCache()); return toDesktopConfig(loadCache());
@@ -407,6 +481,30 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
if (typeof patch.ignoredUpdateVersion === 'string') { if (typeof patch.ignoredUpdateVersion === 'string') {
persisted.ignoredUpdateVersion = patch.ignoredUpdateVersion; persisted.ignoredUpdateVersion = patch.ignoredUpdateVersion;
} }
if (patch.autoLaunch !== undefined) {
persisted.autoLaunch = patch.autoLaunch === true;
}
if (patch.autoUpdate !== undefined) {
persisted.autoUpdate = patch.autoUpdate === true;
}
if (patch.developerMode !== undefined) {
persisted.developerMode = patch.developerMode === true;
}
if (patch.careMode !== undefined) {
persisted.careMode = patch.careMode === true;
}
// 待安装的更新只由主进程的 auto-update 模块写入;渲染进程传进来也照收(退出登录/重置走 clearConfig)
if (typeof patch.pendingUpdateVersion === 'string') {
persisted.pendingUpdateVersion = patch.pendingUpdateVersion;
}
if (typeof patch.pendingUpdateFile === 'string') {
persisted.pendingUpdateFile = patch.pendingUpdateFile;
}
// deviceKey **不接受外部改写**:它是这台机器在服务端的身份,改了服务端就会当成一台新机器
// (旧记录永远停在上一次心跳、新记录从零开始)。要重置身份只能手工删掉 config.json 里的该字段。
if (typeof patch.deviceKey === 'string' && patch.deviceKey && patch.deviceKey !== persisted.deviceKey) {
logger.warn('忽略对设备标识(deviceKey)的改写:它是这台机器在服务端的身份,不允许变更');
}
if (patch.token !== undefined) { if (patch.token !== undefined) {
const encrypted = encryptToken(patch.token); const encrypted = encryptToken(patch.token);
persisted.token = encrypted.token; persisted.token = encrypted.token;
@@ -419,9 +517,27 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
return toDesktopConfig(state); return toDesktopConfig(state);
} }
/** 清空为默认配置(退出登录 / 重置)。立即落盘:它对应退出登录,不该被合并窗口延迟 */ /**
* 清空为默认配置(退出登录 / 重置)。立即落盘:它对应退出登录,不该被合并窗口延迟。
*
* **保留"这台机器"的身份与本地开关**:`deviceKey`(服务端按它识别设备,换了就等于换一台机器)、
* 开机自启 / 自动更新 / 开发者模式 / 关怀模式,以及已下载待安装的更新记录 —— 退出登录不该在服务端
* 多出一台陌生设备,也不该把用户刚打开的开关悄悄关回默认值。
*/
export function clearConfig(): DesktopStoredConfig { export function clearConfig(): DesktopStoredConfig {
const state: ConfigCache = { config: { ...DEFAULT_CONFIG }, token: null }; const current = cache?.config ?? DEFAULT_CONFIG;
const kept: PersistedConfig = {
...DEFAULT_CONFIG,
deviceKey: current.deviceKey ?? '',
autoLaunch: current.autoLaunch === true,
autoUpdate: current.autoUpdate !== false,
developerMode: current.developerMode === true,
careMode: current.careMode === true,
pendingUpdateVersion: current.pendingUpdateVersion ?? '',
pendingUpdateFile: current.pendingUpdateFile ?? '',
};
ensureDeviceKey(kept);
const state: ConfigCache = { config: kept, token: null };
cache = state; cache = state;
scheduleWrite(state.config, true); scheduleWrite(state.config, true);
return toDesktopConfig(state); return toDesktopConfig(state);
@@ -0,0 +1,187 @@
import { app, type BrowserWindow, type RenderProcessGoneDetails } from 'electron';
import { logger } from './logger.js';
/**
* 崩溃自愈(主窗口)。
*
* 教室机的运行环境很不友好:显卡驱动老旧、长期不重启、内存被其它教学软件挤占 ——
* 渲染进程偶发崩溃与无响应是常态。原先这种情况表现为"窗口还在、一片空白、点哪都没反应",
* 只能人工去重启,因此这里补三条自愈路径:
*
* 1. `render-process-gone` —— 渲染进程没了(崩溃 / 被杀),**静默重建窗口**;
* 2. `unresponsive` —— 界面卡住超过 10 秒还没缓过来,同样重建;期间恢复(`responsive`)就取消;
* 3. `uncaughtException` / `unhandledRejection` —— 主进程的未捕获错误,
* 记完整堆栈,并且**只有在本机确认空闲时**才重启自己。
*
* 两条限流(照抄灵动岛 supervisor 的口径,避免"崩溃 → 重建 → 立刻又崩"死循环刷日志):
* **10 分钟内最多 3 次**,超了只记日志、停下来等人工处理。
* 另外**冒烟模式下绝不 `app.relaunch()`**:验证实例重启自己会让父进程拿到一个莫名其妙的子进程。
*/
/** 限流:窗口内最多几次 */
const RECOVER_MAX_TIMES = 3;
/** 限流窗口:10 分钟 */
const RECOVER_WINDOW_MS = 10 * 60_000;
/** 界面无响应多久算卡死(Electron 的 `unresponsive` 事件不会再给第二次机会) */
const UNRESPONSIVE_GRACE_MS = 10_000;
export interface CrashGuardOptions {
/** 销毁并重建主窗口(index.ts 里复用原有的"建窗 + 载入界面"逻辑) */
recoverMainWindow: () => void;
/** 本机是否空闲(不在上课且主窗口不可见)—— 决定主进程崩溃后能不能自动重启 */
isIdle: () => boolean;
/** 退出应用(复用既有的退出路径,保证配置落盘) */
quitApp: () => void;
}
const windowRecoverAt: number[] = [];
const restartAt: number[] = [];
let unresponsiveTimer: ReturnType<typeof setTimeout> | null = null;
/** 正在重建:重建过程中窗口又会发 `closed` / `destroyed`,不能再触发一次 */
let recovering = false;
/** 当前主窗口(重建后会换成新实例) */
let mainWindow: BrowserWindow | null = null;
/** install 时拿到的回调集合(事件回调里复用) */
let options: CrashGuardOptions | null = null;
/**
* 安装崩溃自愈(在 `app.whenReady()` 里、主窗口建好之后调用一次)。
*/
export function installCrashGuard(guard: CrashGuardOptions): void {
options = guard;
app.on('render-process-gone', (_event, contents, details: RenderProcessGoneDetails) => {
// 灵动岛窗口有自己的 supervisor(island.ts),这里只管主窗口
if (!isMainWindowContents(contents.id)) return;
logger.error(
`主窗口渲染进程异常退出:reason=${details.reason} exitCode=${details.exitCode}(准备重建窗口)`,
);
recoverWindow(`渲染进程退出(${details.reason})`);
});
app.on('child-process-gone', (_event, details) => {
logger.error(
`子进程异常退出:type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`,
);
});
process.on('uncaughtException', (error) => {
logger.error('主进程未捕获异常:', error);
restartIfIdle('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
logger.error('主进程未处理的 Promise 拒绝:', reason);
restartIfIdle('unhandledRejection');
});
}
/**
* 记录当前主窗口并挂上无响应监听。
* `createWindow()` 之后调用;窗口重建后要重新调用一次(新实例、新监听)。
*/
export function watchMainWindow(win: BrowserWindow): void {
mainWindow = win;
win.on('unresponsive', () => {
logger.error('主窗口界面无响应(10 秒内未恢复将重建窗口)');
armUnresponsiveTimer();
});
win.on('responsive', () => {
if (unresponsiveTimer !== null) {
clearTimeout(unresponsiveTimer);
unresponsiveTimer = null;
logger.info('主窗口界面已恢复响应,取消重建');
}
});
win.on('closed', () => {
if (mainWindow === win) mainWindow = null;
if (unresponsiveTimer !== null) {
clearTimeout(unresponsiveTimer);
unresponsiveTimer = null;
}
});
}
function armUnresponsiveTimer(): void {
if (unresponsiveTimer !== null) clearTimeout(unresponsiveTimer);
unresponsiveTimer = setTimeout(() => {
unresponsiveTimer = null;
if (!mainWindow || mainWindow.isDestroyed()) return;
recoverWindow('界面无响应超过 10 秒');
}, UNRESPONSIVE_GRACE_MS);
}
function isMainWindowContents(id: number): boolean {
// 主窗口引用还没建立(启动早期)时按"就是它"处理,避免漏掉重建
if (!mainWindow || mainWindow.isDestroyed()) return true;
return mainWindow.webContents.id === id;
}
/** 静默重建主窗口(限流) */
function recoverWindow(reason: string): void {
const recover = options?.recoverMainWindow;
if (recovering) return;
if (!recover) {
logger.error(`主窗口需要重建(${reason}),但没有可用的重建回调`);
return;
}
const now = Date.now();
while (windowRecoverAt.length > 0 && now - windowRecoverAt[0] > RECOVER_WINDOW_MS) {
windowRecoverAt.shift();
}
if (windowRecoverAt.length >= RECOVER_MAX_TIMES) {
logger.error(
`主窗口在 ${RECOVER_WINDOW_MS / 60_000} 分钟内已重建 ${RECOVER_MAX_TIMES} 次,停止自动重建(${reason})` +
':请检查显卡驱动或教学软件冲突后手动重启应用',
);
return;
}
windowRecoverAt.push(now);
recovering = true;
try {
const win = mainWindow;
if (win && !win.isDestroyed()) {
// 先隐藏再销毁:跟灵动岛的 supervisor 一样,避免留住一帧残影/白屏
win.hide();
}
logger.warn(`开始重建主窗口(${reason},本窗口期第 ${windowRecoverAt.length} 次)`);
recover();
} catch (error) {
logger.error(`重建主窗口失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
recovering = false;
}
}
/** 主进程崩溃后按需重启自己(限流 + 空闲判定 + 冒烟豁免) */
function restartIfIdle(reason: string): void {
if (process.env.ELECTRON_SMOKE_TEST === '1') {
// 冒烟模式只记日志:验证实例重启自己会污染验证结果
logger.warn(`冒烟模式:不因 ${reason} 重启应用(仅记录日志)`);
return;
}
let idle = false;
try {
idle = options?.isIdle() === true;
} catch (error) {
logger.error(`判断本机是否空闲时出错:${error instanceof Error ? error.message : String(error)}`);
}
if (!idle) {
logger.warn(`本机正在使用中(${reason}),不自动重启应用,以免打断课堂`);
return;
}
const now = Date.now();
while (restartAt.length > 0 && now - restartAt[0] > RECOVER_WINDOW_MS) {
restartAt.shift();
}
if (restartAt.length >= RECOVER_MAX_TIMES) {
logger.error(
`应用在 ${RECOVER_WINDOW_MS / 60_000} 分钟内已自动重启 ${RECOVER_MAX_TIMES} 次,停止自动重启(${reason})`,
);
return;
}
restartAt.push(now);
logger.warn(`本机空闲,因 ${reason} 自动重启应用(本窗口期第 ${restartAt.length} 次)`);
app.relaunch();
options?.quitApp();
}
+103 -18
View File
@@ -1,7 +1,10 @@
import path from 'node:path'; import path from 'node:path';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { BrowserWindow, app, shell } from 'electron'; import { BrowserWindow, app, shell } from 'electron';
import { setQuitHandler, watchMainWindow as watchAutoUpdateWindow } from './auto-update.js';
import { startClientAgent, stopClientAgent } from './client-agent.js';
import { flushConfigSync, getConfig } from './config.js'; import { flushConfigSync, getConfig } from './config.js';
import { installCrashGuard, watchMainWindow as watchCrashGuardWindow } from './crash-guard.js';
import { registerIpcHandlers } from './ipc.js'; import { registerIpcHandlers } from './ipc.js';
import { island, registerIslandIpc } from './island.js'; import { island, registerIslandIpc } from './island.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
@@ -16,6 +19,13 @@ const currentDir = __dirname;
const devServerUrl = process.env.VITE_DEV_SERVER_URL; const devServerUrl = process.env.VITE_DEV_SERVER_URL;
const isSmokeTest = process.env.ELECTRON_SMOKE_TEST === '1'; const isSmokeTest = process.env.ELECTRON_SMOKE_TEST === '1';
/**
* 这次启动是否需要"直接进托盘、不弹窗"——开机自启专用(见 `applyAutoLaunch()`)。
* 判定依据是我们自己在登录项里注册的命令行参数 `--hidden`(Electron 44 的
* `setLoginItemSettings` 在 Windows 上**没有** `openAsHidden` 这个开关,只能自己带参数)。
*/
const startHidden = !isSmokeTest && process.argv.includes('--hidden');
// 自动化验证使用独立的 userData:否则用户正在运行的客户端会占着单实例锁, // 自动化验证使用独立的 userData:否则用户正在运行的客户端会占着单实例锁,
// 冒烟进程会"静默退出(退出码 0、无输出)",看起来像构建坏了。 // 冒烟进程会"静默退出(退出码 0、无输出)",看起来像构建坏了。
const smokeProfile = process.env.ELECTRON_SMOKE_PROFILE; const smokeProfile = process.env.ELECTRON_SMOKE_PROFILE;
@@ -50,7 +60,7 @@ function isAllowedNavigation(url: string): boolean {
} }
} }
function createWindow(): BrowserWindow { function createWindow(options: { hidden?: boolean } = {}): BrowserWindow {
// 启动底色跟主题走:深色主题下若仍用浅色底,窗口先白后黑会"闪一下" // 启动底色跟主题走:深色主题下若仍用浅色底,窗口先白后黑会"闪一下"
const theme = getConfig().theme; const theme = getConfig().theme;
const win = new BrowserWindow({ const win = new BrowserWindow({
@@ -61,8 +71,8 @@ function createWindow(): BrowserWindow {
minHeight: 540, minHeight: 540,
center: true, center: true,
title: 'ClassHelper', title: 'ClassHelper',
// 冒烟验证时不弹窗,避免打扰使用者 // 冒烟验证时不弹窗,避免打扰使用者;开机自启(--hidden)时直接进托盘
show: !isSmokeTest, show: !isSmokeTest && options.hidden !== true,
autoHideMenuBar: true, autoHideMenuBar: true,
backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3', backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3',
webPreferences: { webPreferences: {
@@ -87,12 +97,34 @@ function createWindow(): BrowserWindow {
}); });
win.on('closed', () => { win.on('closed', () => {
mainWindow = null; if (mainWindow === win) mainWindow = null;
}); });
// 崩溃自愈与「空闲安装更新」都要盯着主窗口:窗口重建后换的是新实例,因此每次建窗都重挂一次
watchCrashGuardWindow(win);
watchAutoUpdateWindow(win);
return win; return win;
} }
/**
* 取当前主窗口,必要时重建。
*
* 与 `createWindow()` 的区别:这里会把"窗口已经被崩溃自愈销毁 / 还没建"的情况一并处理掉
* (托盘、二次启动、`activate` 都通过它拿窗口),因此调用方拿到的要么是活着的窗口、要么是 null。
*/
function ensureMainWindow(options: { hidden?: boolean } = {}): BrowserWindow | null {
if (mainWindow && !mainWindow.isDestroyed()) return mainWindow;
try {
mainWindow = createWindow(options);
void loadRenderer(mainWindow);
} catch (error) {
logger.error(`创建主窗口失败:${error instanceof Error ? error.message : String(error)}`);
mainWindow = null;
}
return mainWindow;
}
async function loadRenderer(win: BrowserWindow): Promise<void> { async function loadRenderer(win: BrowserWindow): Promise<void> {
if (devServerUrl) { if (devServerUrl) {
await win.loadURL(devServerUrl); await win.loadURL(devServerUrl);
@@ -103,24 +135,74 @@ async function loadRenderer(win: BrowserWindow): Promise<void> {
await win.loadFile(path.join(currentDir, '../renderer/index.html')); await win.loadFile(path.join(currentDir, '../renderer/index.html'));
} }
/**
* 按配置里的 `autoLaunch` 应用「开机自启」。
*
* 两个实测过的坑:
* 1. Electron 44 的 `setLoginItemSettings` 在 **Windows 上没有 `openAsHidden`**
* (那是旧的 macOS 选项),所以"登录后不弹主窗口"只能自己带参数 —— 启动时注册 `--hidden`,
* `createWindow()` 看到它就把窗口建成隐藏态(见 `startHidden`);
* 2. **冒烟验证绝不写登录项**:那会改开发机真实的注册表启动项,属于"验证污染环境"。
*/
function applyAutoLaunch(): void {
if (isSmokeTest) return;
const enabled = getConfig().autoLaunch === true;
try {
app.setLoginItemSettings({
openAtLogin: enabled,
path: process.execPath,
args: enabled ? ['--hidden'] : [],
});
logger.info(enabled ? '已开启开机自启(登录 Windows 后静默启动,不弹主窗口)' : '开机自启未开启');
} catch (error) {
logger.warn(
`设置开机自启失败(不影响主功能,可在设置页重试):${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
// 单实例:重复启动时聚焦已有窗口 // 单实例:重复启动时聚焦已有窗口
const gotLock = app.requestSingleInstanceLock(); const gotLock = app.requestSingleInstanceLock();
if (!gotLock) { if (!gotLock) {
app.quit(); app.quit();
} else { } else {
app.on('second-instance', () => { app.on('second-instance', () => {
if (!mainWindow) return; showMainWindow();
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}); });
app.whenReady().then(async () => { app.whenReady().then(async () => {
// 日志级别跟开发者模式走:debug 只在开启时才输出(设置页「开发者模式」)
logger.setVerbose(getConfig().developerMode === true);
registerIpcHandlers(); registerIpcHandlers();
registerIslandIpc(); registerIslandIpc();
mainWindow = createWindow(); // 开机自启:登录项要与配置保持一致(用户在设置页改过之后由 IPC 立即同步)
applyAutoLaunch();
mainWindow = createWindow({ hidden: startHidden });
await loadRenderer(mainWindow); await loadRenderer(mainWindow);
// 崩溃自愈:主窗口渲染进程崩溃 / 界面无响应时静默重建;主进程崩溃且本机空闲时自动重启
installCrashGuard({
recoverMainWindow: () => {
const alive = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null;
if (alive) {
alive.hide();
alive.destroy();
}
mainWindow = ensureMainWindow();
},
isIdle: () =>
island.getState().inClass === false &&
mainWindow?.isVisible() !== true &&
!BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isVisible()),
quitApp: () => quitApp(),
});
// 空闲安装更新:先把手动退出的回调交给 auto-update(它不能再 import 本文件,否则循环导入)
setQuitHandler(() => quitApp());
// 灵动岛:独立的置顶透明小窗口(桌面中上方) // 灵动岛:独立的置顶透明小窗口(桌面中上方)
try { try {
await island.init(devServerUrl ? `${devServerUrl.replace(/\/+$/, '')}/island.html` : null); await island.init(devServerUrl ? `${devServerUrl.replace(/\/+$/, '')}/island.html` : null);
@@ -167,6 +249,9 @@ if (!gotLock) {
// 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts // 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts
scheduleStartupUpdateCheck(() => mainWindow); scheduleStartupUpdateCheck(() => mainWindow);
// 服务端统一下发:定时上报本机状态并领取「待执行命令 / 统一更新」(冒烟默认不上报)
startClientAgent();
if (isSmokeTest) { if (isSmokeTest) {
try { try {
await runSmokeTest(mainWindow); await runSmokeTest(mainWindow);
@@ -177,15 +262,13 @@ if (!gotLock) {
} }
}); });
/** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动) */ /** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动 / 灵动岛"打开应用") */
function showMainWindow(): void { function showMainWindow(): void {
if (!mainWindow || mainWindow.isDestroyed()) { const win = ensureMainWindow();
mainWindow = createWindow(); if (!win) return;
void loadRenderer(mainWindow); if (win.isMinimized()) win.restore();
} win.show();
if (mainWindow.isMinimized()) mainWindow.restore(); win.focus();
mainWindow.show();
mainWindow.focus();
} }
/** /**
@@ -194,6 +277,8 @@ if (!gotLock) {
*/ */
function shutdownResources(): string { function shutdownResources(): string {
const done: string[] = []; const done: string[] = [];
// 上报代理先停:否则退出过程中它还会再排一次心跳(幂等,重复调用无副作用)
stopClientAgent();
// 配置是**异步合并写**的(见 main/config.ts):退出前必须同步落地一次, // 配置是**异步合并写**的(见 main/config.ts):退出前必须同步落地一次,
// 否则"刚改完设置就退出"这一类操作会丢掉最后一次改动。 // 否则"刚改完设置就退出"这一类操作会丢掉最后一次改动。
// 放在最前面:后面会销毁窗口与灵动岛,先落盘不依赖任何窗口是否还在。 // 放在最前面:后面会销毁窗口与灵动岛,先落盘不依赖任何窗口是否还在。
@@ -243,8 +328,8 @@ if (!gotLock) {
app.on('activate', async () => { app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow(); const win = ensureMainWindow();
await loadRenderer(mainWindow); if (win) await loadRenderer(win);
} }
}); });
} }
+85 -11
View File
@@ -1,19 +1,15 @@
import { app, ipcMain, shell } from 'electron'; import { app, BrowserWindow, ipcMain, shell } from 'electron';
import type { UpdateInfo } from '@classhelper/shared'; import type { UpdateInfo } from '@classhelper/shared';
import { IPC_CHANNELS } from './channels.js';
import { beatNow, getClientAgentStatus } from './client-agent.js';
import { clearConfig, getConfig, getConfigPath, saveConfig } from './config.js'; import { clearConfig, getConfig, getConfigPath, saveConfig } from './config.js';
import { logger } from './logger.js';
import { checkForUpdates, ignoreUpdateVersion } from './update.js'; import { checkForUpdates, ignoreUpdateVersion } from './update.js';
import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js'; import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
/** IPC 通道名集中管理,preload 与主进程共用同一份字符串 */ // 通道名定义搬到了叶子模块 `channels.ts`(原因见那里的注释:preload 在沙箱里不能碰 node 内建模块);
export const IPC_CHANNELS = { // 这里原样再导出,保持既有 `import { IPC_CHANNELS } from './ipc.js'` 的调用点不变。
getConfig: 'classhelper:config:get', export { IPC_CHANNELS };
saveConfig: 'classhelper:config:save',
clearConfig: 'classhelper:config:clear',
appInfo: 'classhelper:app:info',
openExternal: 'classhelper:shell:open-external',
updateCheck: 'classhelper:update:check',
updateIgnore: 'classhelper:update:ignore',
} as const;
export function registerIpcHandlers(): void { export function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.getConfig, (): DesktopStoredConfig => getConfig()); ipcMain.handle(IPC_CHANNELS.getConfig, (): DesktopStoredConfig => getConfig());
@@ -58,6 +54,84 @@ export function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.updateIgnore, (_event, version: string): void => { ipcMain.handle(IPC_CHANNELS.updateIgnore, (_event, version: string): void => {
if (typeof version === 'string') ignoreUpdateVersion(version); if (typeof version === 'string') ignoreUpdateVersion(version);
}); });
/** 开机自启:读的是系统登录项,不是配置文件里的值(两者可能被外部改过) */
ipcMain.handle(IPC_CHANNELS.getAutoLaunch, (): boolean => {
try {
return app.getLoginItemSettings().openAtLogin === true;
} catch {
return getConfig().autoLaunch === true;
}
});
/**
* 开机自启:写配置 + 同步系统登录项。
*
* 与 `main/index.ts` 的 `applyAutoLaunch()` 同一套参数(`--hidden` 是"登录后不弹主窗口"的实现方式,
* 因为 Electron 44 在 Windows 上没有 `openAsHidden`),返回**系统实际生效**的值。
*/
ipcMain.handle(IPC_CHANNELS.setAutoLaunch, (_event, enabled: boolean): boolean => {
const wanted = enabled === true;
saveConfig({ autoLaunch: wanted });
try {
app.setLoginItemSettings({
openAtLogin: wanted,
path: process.execPath,
args: wanted ? ['--hidden'] : [],
});
return app.getLoginItemSettings().openAtLogin === true;
} catch (error) {
logger.warn(
`写入开机自启失败(配置已保存,下次启动会重试):${
error instanceof Error ? error.message : String(error)
}`,
);
return wanted;
}
});
/** 打开日志目录(设置页「打开日志目录」;日志文件是 main.log,见 main/logger.ts) */
ipcMain.handle(IPC_CHANNELS.openLogDir, async (): Promise<boolean> => {
try {
const message = await shell.openPath(app.getPath('logs'));
if (message) {
logger.warn(`打开日志目录失败:${message}`);
return false;
}
return true;
} catch (error) {
logger.warn(`打开日志目录失败:${error instanceof Error ? error.message : String(error)}`);
return false;
}
});
/**
* 打开开发者工具(「开发者」页的按钮;detached 独立窗口)。
*
* 两处刻意的限制:
* - **冒烟模式下直接拒绝**。冒烟验证会给窗口注入合成事件、测量侧栏几何、整屏截图取样;
* 多出一个 DevTools 窗口会抢焦点、并且 detached 窗口会改变 `capturePage()` 的取样范围,
* 把既有断言打乱。返回 false 让页面提示"冒烟模式下不可用"。
* - 窗口取自 `event.sender` 而不是模块级变量:IPC 只可能由主窗口(或无关的岛窗口)发起,
* 按发送方取窗口既不需要在 ipc.ts 里持有 mainWindow 引用(避免与 main/index.ts 循环依赖),
* 也保证打开的是**发起调用的那个窗口**的 DevTools。
*/
ipcMain.handle(IPC_CHANNELS.openDevTools, (event): boolean => {
if (process.env.ELECTRON_SMOKE_TEST === '1') {
logger.warn('冒烟验证模式下不允许打开开发者工具(会打乱焦点与截图断言)');
return false;
}
const target = BrowserWindow.fromWebContents(event.sender);
if (!target || target.isDestroyed()) return false;
target.webContents.openDevTools({ mode: 'detach' });
return true;
});
/** 服务端联动状态(纯数据:跨 contextBridge 只能传普通对象,见 AGENTS.md §5 第 9 条) */
ipcMain.handle(IPC_CHANNELS.clientStatus, () => getClientAgentStatus());
/** 立即上报一次(设置页「立即上报」按钮) */
ipcMain.handle(IPC_CHANNELS.clientBeatNow, () => beatNow());
} }
export function getDiagnostics(): { configPath: string; config: DesktopStoredConfig } { export function getDiagnostics(): { configPath: string; config: DesktopStoredConfig } {
+68 -2
View File
@@ -101,6 +101,15 @@ let SLIVER_SIZE = { width: ISLAND_SLIVER_WIDTH, height: 22 };
*/ */
let BBOX_SIZE = { width: 0, height: 0 }; let BBOX_SIZE = { width: 0, height: 0 };
/**
* 「收回动画」时长(毫秒)。
*
* 用户点了「知道了 / 标为已读」之后,主进程把形态切回胶囊、让渲染进程沿**既有的形变路径**
* 把卡片收回去,到点再淡出隐藏。取值与渲染进程的形变弹簧(约 300~500ms)同量级;
* 外观设置里的"动画速度"会同比缩放它(见 `beginClose()`)。
*/
const CLOSE_ANIMATION_MS = 360;
/** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */ /** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */
const TIMEOUTS = { const TIMEOUTS = {
pill: 15_000, pill: 15_000,
@@ -150,6 +159,11 @@ class IslandController {
private ready = false; private ready = false;
private pendingState: IslandState | null = null; private pendingState: IslandState | null = null;
private collapseTimer: ReturnType<typeof setTimeout> | null = null; private collapseTimer: ReturnType<typeof setTimeout> | null = null;
/**
* 「收回动画」定时器:点知道了 / 标为已读之后,延时把窗口淡出隐藏。
* 任何让岛重新可见的路径(`activate()` 等)都会经 `clearTimers()` 取消它。
*/
private closeTimer: ReturnType<typeof setTimeout> | null = null;
/** 尺寸(形变)动画令牌:新动画会作废上一轮,避免互相打架 */ /** 尺寸(形变)动画令牌:新动画会作废上一轮,避免互相打架 */
private boundsToken = 0; private boundsToken = 0;
/** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */ /** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */
@@ -786,6 +800,48 @@ class IslandController {
this.setState({ mode: 'hidden', reason: null }); this.setState({ mode: 'hidden', reason: null });
return; return;
} }
this.beginClose();
}
/**
* 收回动画:把形态切回胶囊,让渲染进程沿**既有的胶囊形变路径**把卡片收回去,
* `CLOSE_ANIMATION_MS` 之后再淡出隐藏。
*
* 起因:用户反馈"点「知道了」「已读」时没有收回动画"(其他场景都有)。
* 状态**在同一帧就已经落地**(调用方 `dismissActive()` / `clearPendingForClose()` 已清空
* active/queued),这里只是把形态切回胶囊 —— **不引入任何跨进程握手,也没有收回快照**
* (见 AGENTS.md §5 第 34 条:早先自研的 `closing` / `closingSnapshot` 已整体删除,
* 它在触摸屏上会露旧帧;这里复用的是已被 `collapse` 用例验收过的那条路径)。
*/
private beginClose(): void {
// 关掉动画(含"减少动态效果"):保持原行为,直接淡出
if (!this.appearance.animations) {
this.finishClose();
return;
}
/**
* **必须 `skipWindow`**:收回动画是"卡片沿既有的胶囊形变路径缩回去",
* 而窗口此刻还是展开/列表态的大包围盒 —— 若这里同步缩窗,卡片会在动画的 360ms 里
* 被窗口裁掉(冒烟用例「整批关闭过程中卡片不被窗口裁切」实测最大越界 554.9px,
* 用户看到的是一瞬间的硬裁边)。窗口要变小一律等**隐藏之后**由 `syncWindow()` 做,
* 这与触摸模式那条"窗口可见期间只增不减"(AGENTS.md §5 第 30 条)是同一条纪律。
*/
this.setState({ mode: 'pill', reason: 'closing' }, { skipWindow: true });
const speed = clamp(this.appearance.speed, 0.5, 2, 1);
this.closeTimer = setTimeout(() => {
this.closeTimer = null;
this.finishClose();
}, Math.round(CLOSE_ANIMATION_MS / speed));
}
/**
* 收回动画收尾:淡出窗口并把状态落成 hidden —— 与原来的 `hide()` 完全一致,
* 所以"窗口最终会隐藏"的既有断言(含轮询写法)不受影响。
*/
private finishClose(): void {
// 动画期间又来了新消息 / 被显式唤起:这次收回归属于旧的一批,放弃收尾
if (this.state.mode !== 'pill' || this.state.reason !== 'closing') return;
if (this.state.active || this.state.queued.length > 0) return;
this.fadeOut(); this.fadeOut();
this.setState({ mode: 'hidden', reason: null }, { skipWindow: true }); this.setState({ mode: 'hidden', reason: null }, { skipWindow: true });
} }
@@ -908,6 +964,11 @@ class IslandController {
clearTimeout(this.collapseTimer); clearTimeout(this.collapseTimer);
this.collapseTimer = null; this.collapseTimer = null;
} }
// 收回动画定时器:岛一旦被重新唤起(新消息/显式展开/预览),必须取消这次收尾
if (this.closeTimer) {
clearTimeout(this.closeTimer);
this.closeTimer = null;
}
if (this.previewTimer) { if (this.previewTimer) {
clearTimeout(this.previewTimer); clearTimeout(this.previewTimer);
this.previewTimer = null; this.previewTimer = null;
@@ -1690,8 +1751,6 @@ export function getSliverSize(): { width: number; height: number } {
return { ...SLIVER_SIZE }; return { ...SLIVER_SIZE };
} }
/** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */
export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
/** /**
* 夹紧数值:**非法值回落到该字段的默认值**,而不是变成 NaN。 * 夹紧数值:**非法值回落到该字段的默认值**,而不是变成 NaN。
* *
@@ -1700,11 +1759,18 @@ export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
* 渲染进程传一个显式 `undefined` 字段就会得到 NaN,随后 `setOpacity(NaN)` / `setBounds({x:NaN})` * 渲染进程传一个显式 `undefined` 字段就会得到 NaN,随后 `setOpacity(NaN)` / `setBounds({x:NaN})`
* 抛异常;它在非 async 的 ipcMain 监听里抛出,主进程没有兜底,会直接崩掉 * 抛异常;它在非 async 的 ipcMain 监听里抛出,主进程没有兜底,会直接崩掉
* (连带托盘、灵动岛、实时连接一起消失)。因此这里补上 fallback。 * (连带托盘、灵动岛、实时连接一起消失)。因此这里补上 fallback。
*
* 提到模块作用域:收回动画(`beginClose()`)也要用它按 `appearance.speed` 折算时长,
* 原先它只定义在 `normalizeAppearance()` 内部,`beginClose()` 引用不到(编译报
* `TS2304: Cannot find name 'clamp'`)。
*/ */
const clamp = (value: number, min: number, max: number, fallback: number): number => { const clamp = (value: number, min: number, max: number, fallback: number): number => {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback; if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value * 100) / 100)); return Math.min(max, Math.max(min, Math.round(value * 100) / 100));
}; };
/** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */
export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
const fallbackOf = DEFAULT_ISLAND_APPEARANCE; const fallbackOf = DEFAULT_ISLAND_APPEARANCE;
return { return {
height: clamp(input.height, 36, 72, fallbackOf.height), height: clamp(input.height, 36, 72, fallbackOf.height),
+106 -5
View File
@@ -1,25 +1,126 @@
import fs from 'node:fs';
import path from 'node:path';
import { app } from 'electron';
/** /**
* 主进程日志(控制台输出)。 * 主进程日志:控制台输出 + 落盘。
*
* 打包后的 GUI 进程没有可见的控制台,因此关键信息同时通过: * 打包后的 GUI 进程没有可见的控制台,因此关键信息同时通过:
* - 冒烟验证的 ELECTRON_SMOKE_RESULT 结果文件 * - 冒烟验证的 ELECTRON_SMOKE_RESULT 结果文件
* - 主窗口右上角的连接状态 / 灵动岛状态 * - 主窗口右上角的连接状态 / 灵动岛状态
* - **本文件写出的日志文件**(设置页「打开日志目录」直达)
* 对外可见,避免"出了问题却看不到日志"。 * 对外可见,避免"出了问题却看不到日志"。
*
* 两条实现约束:
* 1. `app.getPath('logs')` **不能在 app ready 之前调用**,所以日志文件路径是**懒解析**的:
* 解析失败(或还没 ready)时只走 console,之后任意一次写入会重新尝试;
* 2. 单文件超过上限就改名成 `main.log.1`(只留一份旧的,更老的直接删除),
* 避免长期运行的教室机器把磁盘写满。
*/ */
/** 单文件大小上限:超过就轮转(1MB 足够放下很多次崩溃堆栈) */
const LOG_MAX_BYTES = 1024 * 1024;
/** 轮转后的旧文件名 */
const LOG_ROTATED_NAME = 'main.log.1';
/** 日志文件名(在 app ready 之后解析;null = 暂不可用) */
let filePath: string | null = null;
/** verbose(= 配置里的 developerMode)时 debug 级别才输出 */
let verbose = false;
/** 解析日志文件路径:失败只返回 null(不抛),调用方继续走 console */
function resolveLogFile(): string | null {
try {
return path.join(app.getPath('logs'), 'main.log');
} catch {
// app ready 之前调用会抛;此时不写文件即可,控制台输出不受影响
return null;
}
}
/** 文件超过上限就轮转:main.log → main.log.1(覆盖上一份旧的) */
function rotateIfNeeded(target: string): void {
try {
const stat = fs.statSync(target);
if (stat.size <= LOG_MAX_BYTES) return;
const rotated = path.join(path.dirname(target), LOG_ROTATED_NAME);
// 先删旧的再改名:只保留一份历史日志
fs.rmSync(rotated, { force: true });
fs.renameSync(target, rotated);
} catch {
// 文件还不存在(第一次写)/ 被占用:都无需处理
}
}
/** 落盘一行。任何失败都静默忽略 —— 日志不能反过来把主进程搞崩 */
function write(line: string): void {
if (!filePath) filePath = resolveLogFile();
const target = filePath;
if (!target) return;
try {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.appendFileSync(target, `${line}\n`, 'utf8');
rotateIfNeeded(target);
} catch {
// 忽略:磁盘满 / 权限不足等都不该影响应用运行
}
}
function timestamp(): string { function timestamp(): string {
return new Date().toISOString(); return new Date().toISOString();
} }
export const logger = { export const logger = {
/**
* 设置日志级别(由 `main/index.ts` 按 `config.developerMode` 调用)。
* 开启后 debug 级别的日志才会同时进控制台与日志文件。
*/
setVerbose(enabled: boolean): void {
verbose = enabled === true;
},
/** 当前是否 verbose(供设置页/冒烟断言) */
isVerbose(): boolean {
return verbose;
},
debug(message: string, ...args: unknown[]): void { debug(message: string, ...args: unknown[]): void {
console.log(`[${timestamp()}] DEBUG ${message}`, ...args); if (!verbose) return;
const line = `[${timestamp()}] DEBUG ${message}`;
console.log(line, ...args);
write(`${line}${formatArgs(args)}`);
}, },
info(message: string, ...args: unknown[]): void { info(message: string, ...args: unknown[]): void {
console.log(`[${timestamp()}] INFO ${message}`, ...args); const line = `[${timestamp()}] INFO ${message}`;
console.log(line, ...args);
write(`${line}${formatArgs(args)}`);
}, },
warn(message: string, ...args: unknown[]): void { warn(message: string, ...args: unknown[]): void {
console.warn(`[${timestamp()}] WARN ${message}`, ...args); const line = `[${timestamp()}] WARN ${message}`;
console.warn(line, ...args);
write(`${line}${formatArgs(args)}`);
}, },
error(message: string, ...args: unknown[]): void { error(message: string, ...args: unknown[]): void {
console.error(`[${timestamp()}] ERROR ${message}`, ...args); const line = `[${timestamp()}] ERROR ${message}`;
console.error(line, ...args);
write(`${line}${formatArgs(args)}`);
}, },
}; };
/**
* 额外参数转成一行文本(落盘用)。
*
* 崩溃自愈那边传进来的常常是 Error 对象,**必须把完整堆栈写进文件**,
* 否则事后再看 `main.log` 只有一个光秃秃的 message,等于没记。
*/
function formatArgs(args: unknown[]): string {
if (args.length === 0) return '';
const parts = args.map((item) => {
if (item instanceof Error) return item.stack ?? `${item.name}: ${item.message}`;
if (typeof item === 'string') return item;
try {
return JSON.stringify(item);
} catch {
return String(item);
}
});
return ` ${parts.join(' ')}`;
}
+33
View File
@@ -2029,6 +2029,39 @@ async function runIslandChecks(
); );
await sleep(120); await sleep(120);
const duringClose = { state: island.getState() }; const duringClose = { state: island.getState() };
/**
* 收回动画(用户要求:"点知道了 / 标为已读时也要有回收动画")。
*
* 主进程收到 dismiss / dismiss-all / mark-read / mark-all-read 之后:状态**同一帧就清空**,
* 形态先切回胶囊(`reason === 'closing'`)让渲染进程把卡片收回去,`CLOSE_ANIMATION_MS`(360ms,
* 会按外观里的"动画速度"缩放)之后才淡出隐藏。这里在点击后的小窗口内轮询采样,
* 断言"确实经过了这个阶段、且动画期间窗口仍然可见"。
*/
let closingSample: { mode: string; reason: string | null; visible: boolean } | null = null;
for (let attempt = 0; attempt < 20 && !closingSample; attempt += 1) {
const probe = island.getState();
if (probe.reason === 'closing') {
closingSample = {
mode: probe.mode,
reason: probe.reason,
visible: islandWindow?.isVisible() ?? false,
};
break;
}
await sleep(25);
}
record(
'点「知道了」有收回动画(先收回胶囊再淡出,动画期间窗口可见)',
closingSample !== null &&
closingSample.mode === 'pill' &&
closingSample.visible &&
duringClose.state.active === null,
closingSample
? `采样=${closingSample.mode}/${closingSample.reason}/可见:${closingSample.visible}`
: '未捕捉到 closing 阶段(可能动画被关掉或耗时过短)',
);
for (let attempt = 0; attempt < 26; attempt += 1) { for (let attempt = 0; attempt < 26; attempt += 1) {
await probeClosingClip(); await probeClosingClip();
await sleep(40); await sleep(40);
+3 -2
View File
@@ -10,6 +10,7 @@ import {
type UpdateAssetDto, type UpdateAssetDto,
type UpdateInfo, type UpdateInfo,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { UPDATE_AVAILABLE_CHANNEL } from './channels.js';
import { getConfig, saveConfig } from './config.js'; import { getConfig, saveConfig } from './config.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
@@ -25,8 +26,8 @@ import { logger } from './logger.js';
* 客户端没有这个限制,按用户选择**直连 GitHub**。两边归一化出的 `UpdateInfo` 结构一致。 * 客户端没有这个限制,按用户选择**直连 GitHub**。两边归一化出的 `UpdateInfo` 结构一致。
*/ */
/** 主进程 → 渲染进程:发现新版本(渲染进程据此弹一次性提示) */ /** 主进程 → 渲染进程:发现新版本(渲染进程据此弹一次性提示);定义在叶子模块 `channels.ts` */
export const UPDATE_AVAILABLE_CHANNEL = 'classhelper:update:available'; export { UPDATE_AVAILABLE_CHANNEL };
/** 启动后多久自动查一次:等窗口与登录流程就绪,也不跟首屏请求抢带宽 */ /** 启动后多久自动查一次:等窗口与登录流程就绪,也不跟首屏请求抢带宽 */
const STARTUP_DELAY_MS = 5_000; const STARTUP_DELAY_MS = 5_000;
+11 -2
View File
@@ -1,6 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from '../main/ipc.js'; import { IPC_CHANNELS, UPDATE_AVAILABLE_CHANNEL } from '../main/channels.js';
import { UPDATE_AVAILABLE_CHANNEL } from '../main/update.js'; import type { ClientAgentStatus } from '../main/client-agent.js';
import type { import type {
DesktopAppInfo, DesktopAppInfo,
DesktopBridge, DesktopBridge,
@@ -66,6 +66,15 @@ const bridge: DesktopBridge = {
onIslandMarkAllRead: (handler: (ids: string[]) => void): void => { onIslandMarkAllRead: (handler: (ids: string[]) => void): void => {
ipcRenderer.on('island:mark-all-read', (_event, ids: string[]) => handler(ids ?? [])); ipcRenderer.on('island:mark-all-read', (_event, ids: string[]) => handler(ids ?? []));
}, },
/* 开机自启 / 日志 / 服务端联动 */
getAutoLaunch: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.getAutoLaunch),
setAutoLaunch: (enabled: boolean): Promise<boolean> =>
ipcRenderer.invoke(IPC_CHANNELS.setAutoLaunch, enabled === true),
openLogDir: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openLogDir),
openDevTools: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openDevTools),
getClientAgentStatus: (): Promise<ClientAgentStatus> => ipcRenderer.invoke(IPC_CHANNELS.clientStatus),
beatClientNow: (): Promise<ClientAgentStatus> => ipcRenderer.invoke(IPC_CHANNELS.clientBeatNow),
}; };
contextBridge.exposeInMainWorld('desktop', bridge); contextBridge.exposeInMainWorld('desktop', bridge);
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
import { ElButton, ElNotification } from 'element-plus'; import { ElButton, ElNotification } from 'element-plus';
import type { UpdateInfo } from '@classhelper/shared'; import type { UpdateInfo } from '@classhelper/shared';
import { import {
replayMissedNotifications,
startIslandBridge, startIslandBridge,
stopIslandBridge, stopIslandBridge,
subscribeIslandMarkAllRead, subscribeIslandMarkAllRead,
@@ -72,6 +73,20 @@ function showUpdateAvailable(info: UpdateInfo): void {
}); });
} }
/**
* 上线补弹「离线期间收到的消息」(需求 11)。
*
* **为什么订阅放在 App.vue**:与 `onUpdateAvailable` 同一条理由 —— App.vue 整个应用生命周期
* 只挂载一次,而 `ClientLayout` 会随"登出 → 登录"反复挂载。而且恢复事件本身是"可达性由不可达
* 变为可达"的那一刻,一次会话里可能发生多次,处理器必须是**幂等**的(`replayMissedNotifications`
* 推完就清掉离线起点,重复触发时第二次读到空值、直接返回 0)。
*
* 处理器是具名函数:`stores/app.ts` 的订阅表按函数引用 off,匿名箭头函数在 onUnmounted 里摘不掉。
*/
function handleServerRecovered(): void {
void replayMissedNotifications();
}
/** /**
* 启动流程: * 启动流程:
* 1) 从主进程配置恢复服务器地址与登录令牌 * 1) 从主进程配置恢复服务器地址与登录令牌
@@ -107,6 +122,9 @@ onMounted(async () => {
subscribeIslandMarkRead(); subscribeIslandMarkRead();
subscribeIslandMarkAllRead(); subscribeIslandMarkAllRead();
// 离线期间收到的消息:可达性恢复的那一刻补弹到灵动岛(App.vue 常驻,所以每次恢复只跑一次)
appStore.onServerRecovered(handleServerRecovered);
// 启动自动检查发现新版本时由主进程推过来(订阅只挂一次,理由见 showUpdateAvailable) // 启动自动检查发现新版本时由主进程推过来(订阅只挂一次,理由见 showUpdateAvailable)
window.desktop?.onUpdateAvailable?.(showUpdateAvailable); window.desktop?.onUpdateAvailable?.(showUpdateAvailable);
@@ -129,6 +147,7 @@ onMounted(async () => {
}); });
onUnmounted(() => { onUnmounted(() => {
appStore.offServerRecovered(handleServerRecovered);
stopIslandBridge(); stopIslandBridge();
}); });
</script> </script>
@@ -1048,6 +1048,15 @@ export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
// 2.2) 导轨必须"一眼看全":先把设置组展开(11 行图标 = 最坏情况),再断言菜单区没有纵向溢出。 // 2.2) 导轨必须"一眼看全":先把设置组展开(11 行图标 = 最坏情况),再断言菜单区没有纵向溢出。
// 折叠态页脚必须把**高度**也收成 0 —— 只淡出不留高度的话,那 ~70px 会把最后两个图标 // 折叠态页脚必须把**高度**也收成 0 —— 只淡出不留高度的话,那 ~70px 会把最后两个图标
// 顶出可视区,用户就得上下滑动(反馈原话:"菜单图标无法完全显示需要上下滑动")。 // 顶出可视区,用户就得上下滑动(反馈原话:"菜单图标无法完全显示需要上下滑动")。
//
// ⚠️ 量之前必须等页脚真的收成 0 高:宽度到 64px 与页脚收拢各有一条 260ms 过渡,
// 而 `--nav-ease`(Material 曲线)在 74% 处就越过"宽度 ≤ 80px"这个等待条件,
// 此时页脚还剩十几像素 —— 直接量 `.menu` 会读出"溢出 16px"的**假红**
// (实测出现过一次:同一份产物上一次全绿、下一次红)。这里等的是**稳态几何**,
// 与这条断言本来的意图一致(CSS 里那句"量到的是稳态")。
const railFooter = document.querySelector<HTMLElement>('.aside-footer');
await waitUntil(() => (railFooter?.offsetHeight ?? 0) <= 1, 2000);
const settingsItem = Array.from( const settingsItem = Array.from(
document.querySelectorAll<HTMLElement>('.aside .el-sub-menu .el-menu-item'), document.querySelectorAll<HTMLElement>('.aside .el-sub-menu .el-menu-item'),
).find((element) => ).find((element) =>
@@ -6,9 +6,9 @@ import {
type NotificationDto, type NotificationDto,
type ScheduleDto, type ScheduleDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { scheduleApi } from '../api/index.js'; import { notificationApi, scheduleApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js'; import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js'; import { clearOfflineSince, readOfflineSince, useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js'; import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js'; import { useNotificationStore } from '../stores/notifications.js';
import { useRealtimeStore } from '../stores/realtime.js'; import { useRealtimeStore } from '../stores/realtime.js';
@@ -190,6 +190,81 @@ export function pushCallToIsland(call: NotificationDto): void {
}); });
} }
/* ------------------------------------------------------------ 补弹离线期间收到的消息(需求 11) */
/**
* 补弹条数上限。
*
* 灵动岛的多条通知列表默认只显示 `ISLAND_LIST_METRICS.defaultVisibleRows`(3)条 +
* 「展开更多(还有 N 条)」。所以一条不补、和一次补十几条,都是同样的阅读体验 ——
* 封顶 5 条 = 3 条可见 + 2 条藏在"展开更多"里,正好是打开就能读完的量;
* 补太多只会让"展开更多"变成一个没有意义的按钮。
*/
const REPLAY_MAX_COUNT = 5;
/**
* 上线后补弹「离线期间收到的消息」(需求 11)。
*
* 调用时机:`App.vue` 订阅 `useAppStore().onServerRecovered`,也就是**可达性由不可达变为可达**
* 的那一刻(断网期间的实时通道是断的,那些通知既没弹过、也没进过用户的视野)。
*
* 数据来源复用既有的通知接口(`notificationApi.list({ unreadOnly: true })`):
* 客户端本来就是离线优先的(`fetchWithCache` 会把数据落 IndexedDB),**不为此新建数据源** ——
* 补弹只需要"服务端现在有哪些未读",而通知列表本来就是这个问题的答案。
*
* 实现上有四条约定:
* 1. **没有离线起点就直接返回 0**(首次启动 / 一直在线):离线起点只由
* `stores/app.ts` 在"由可达变不可达"的那一刻写入,没有它就说明这段时间根本没断过网,
* 此时补弹等于把**历史未读**全弹一遍,那不是「离线期间收到的」。
* 2. **只补离线起点之后创建的**(`createdAt > offlineSince`),并且在服务端排序之外再排一次
* **按时间升序**投递 —— 顺序投递才能让灵动岛的队列保持时间顺序。
* 3. **取最近的 5 条**(升序列表的尾部):断网一天可能积压几十条,取最早的那 5 条等于
* 让用户先看最旧的、最新的反而被"展开更多"挡住。取最近的 5 条更符合"我刚错过了什么"。
* 4. **推完就清掉离线起点**:同一次离线只补一次,否则每次可达性抖动都会重复弹同一批。
*
* @returns 实际补弹的条数(0 = 没补)
*/
export async function replayMissedNotifications(): Promise<number> {
// 冒烟保护:`verify:desktop` / `verify:packaged` 有一条「灵动岛队列」的断言链
// (100+ 条断言量的是卡片的条数、顺序、高度与"展开更多"的分页),
// 冒烟脚本会反复断开/恢复网络,注入的合成通知与这里的补弹会互相打乱队列,
// 让那些断言随机变红。冒烟模式下**直接跳过补弹**(也不动离线起点),
// 补弹本身由人工/单元路径覆盖,不进冒烟。
if (window.desktop?.smokeTest === true) return 0;
const offlineSince = readOfflineSince();
if (!offlineSince) return 0;
const sinceMs = Date.parse(offlineSince);
if (Number.isNaN(sinceMs)) {
// 存储里是坏值(手工改过 / 旧版本写入格式不同):清掉,别让它永远挡住补弹
clearOfflineSince();
return 0;
}
let unread: NotificationDto[];
try {
unread = await notificationApi.list({ unreadOnly: true });
} catch {
// 刚恢复就又断了(或服务端还没真正就绪):保留离线起点,等下一次恢复再补
return 0;
}
const missed = unread
.filter((item) => {
const at = Date.parse(item.createdAt);
return !Number.isNaN(at) && at > sinceMs;
})
.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
const picked = missed.slice(-REPLAY_MAX_COUNT);
for (const item of picked) pushNotificationToIsland(item);
// 无论补到几条都清掉:这一轮离线已经被处理过了
clearOfflineSince();
console.info(`[离线补弹] 补弹离线消息 ${picked.length} 条(离线起点 ${offlineSince})`);
return picked.length;
}
/** /**
* 灵动岛点了"标为已读" → 在通知中心里也标记已读(并刷新未读红点)。 * 灵动岛点了"标为已读" → 在通知中心里也标记已读(并刷新未读红点)。
* 由 App.vue 在挂载时订阅一次。 * 由 App.vue 在挂载时订阅一次。
@@ -25,9 +25,9 @@ const ui = useUiStore();
* *
* - **一级项**:课表 / 作业 / 通知 / 成绩 —— 直接铺在侧栏上,**不再套「学习」分组**。 * - **一级项**:课表 / 作业 / 通知 / 成绩 —— 直接铺在侧栏上,**不再套「学习」分组**。
* 分组标题白占一行、还得多点一次展开,折叠成图标导轨时尤其明显(用户要求拆掉)。 * 分组标题白占一行、还得多点一次展开,折叠成图标导轨时尤其明显(用户要求拆掉)。
* - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由)。 * - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由;
* 6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。折叠后是图标导轨, * 开发者模式下再多一个「开发者」)。6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。
* 分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。 * 折叠后是图标导轨,分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。
* *
* 顶栏左侧汉堡按钮把整栏收成图标导轨(状态持久化在主进程配置里)。 * 顶栏左侧汉堡按钮把整栏收成图标导轨(状态持久化在主进程配置里)。
*/ */
@@ -38,21 +38,39 @@ const primaryItems = [
{ path: '/grades', title: '成绩', icon: 'Trophy' }, { path: '/grades', title: '成绩', icon: 'Trophy' },
]; ];
const menuGroups = [ /** 「设置」分组的常规子项(顺序即侧栏顺序) */
{ const settingItems = [
title: '设置',
icon: 'Setting',
items: [
{ path: '/settings/general', title: '通用', icon: 'Tools' }, { path: '/settings/general', title: '通用', icon: 'Tools' },
{ path: '/settings/appearance', title: '外观', icon: 'Brush' }, { path: '/settings/appearance', title: '外观', icon: 'Brush' },
{ path: '/settings/island', title: '灵动岛', icon: 'MagicStick' }, { path: '/settings/island', title: '灵动岛', icon: 'MagicStick' },
{ path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' }, { path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' },
{ path: '/settings/account', title: '账号', icon: 'UserFilled' }, { path: '/settings/account', title: '账号', icon: 'UserFilled' },
{ path: '/settings/about', title: '关于', icon: 'InfoFilled' }, { path: '/settings/about', title: '关于', icon: 'InfoFilled' },
],
},
]; ];
/**
* 菜单分组(**computed**:子项随开发者模式变化,见下)。
*
* 「开发者」(需求 10)只在 `ui.developerMode === true` 时出现 —— 它是个排查问题的入口
* (版本/日志/更新/DevTools),常驻只会让教室机器上的使用者点进一个看不懂的页面。
*
* 图标用 `Connection`:新增图标必须同时进 `renderer/icons.ts` 的注册表,
* 而那是本任务范围外的文件 —— 所以**只用已注册图标**(`scripts/check-icons.mjs`
* 会在构建前拦下未注册的名字)。
*
* 折叠成图标导轨后多一行图标:开启开发者模式是用户自己的选择,默认关闭 ⇒
* 冒烟(默认配置)里的导轨几何与"不出现滚动条"断言不受影响。
*/
const menuGroups = computed(() => [
{
title: '设置',
icon: 'Setting',
items: ui.developerMode
? [...settingItems, { path: '/settings/developer', title: '开发者', icon: 'Connection' }]
: settingItems,
},
]);
/* -------------------------------------------------------------- 导航指示器 */ /* -------------------------------------------------------------- 导航指示器 */
/** /**
@@ -70,18 +88,46 @@ const menuGroups = [
*/ */
const indicator = ref({ x: 0, y: 0, width: 0, height: 0, visible: false }); const indicator = ref({ x: 0, y: 0, width: 0, height: 0, visible: false });
/**
* 底片的**裁剪框**:与 `.menu` 的滚动视口逐像素重合。
*
* 底片挂在 `.aside` 上,而菜单项是在 `.menu` 里滚动的(`.menu` 是 `flex:1 + min-height:0 +
* overflow-y:auto`,设置组展开时内容约 606px 会超过窗口高度)。激活项被滚出视口时,
* 底片会跟着它跑到品牌区或页脚上 —— 套一层视口大小的 `overflow:hidden`,
* 就是让底片"只在菜单看得见的地方存在"。
*/
const clipStyle = ref<Record<string, string>>({
top: '0px',
left: '0px',
width: '0px',
height: '0px',
});
function syncIndicator(): void { function syncIndicator(): void {
const aside = document.querySelector<HTMLElement>('.aside.ch-nav'); const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
const active = aside?.querySelector<HTMLElement>('.el-menu-item.is-active'); const scroller = aside?.querySelector<HTMLElement>('.menu') ?? null;
if (!aside || !active) { if (!aside || !scroller) {
indicator.value = { ...indicator.value, visible: false }; indicator.value = { ...indicator.value, visible: false };
return; return;
} }
const asideRect = aside.getBoundingClientRect(); const asideRect = aside.getBoundingClientRect();
const boxRect = scroller.getBoundingClientRect();
// 底片的坐标一律相对「菜单视口」,不再相对 `.aside` —— 裁剪框把两者的原点对齐了
clipStyle.value = {
top: `${boxRect.top - asideRect.top}px`,
left: `${boxRect.left - asideRect.left}px`,
width: `${boxRect.width}px`,
height: `${boxRect.height}px`,
};
const active = scroller.querySelector<HTMLElement>('.el-menu-item.is-active');
if (!active) {
indicator.value = { ...indicator.value, visible: false };
return;
}
const rect = active.getBoundingClientRect(); const rect = active.getBoundingClientRect();
indicator.value = { indicator.value = {
x: rect.left - asideRect.left, x: rect.left - boxRect.left,
y: rect.top - asideRect.top, y: rect.top - boxRect.top,
width: rect.width, width: rect.width,
height: rect.height, height: rect.height,
visible: rect.width > 0 && rect.height > 0, visible: rect.width > 0 && rect.height > 0,
@@ -114,6 +160,32 @@ function scheduleIndicatorSync(): void {
}); });
} }
/**
* 菜单内部滚动:底片要**当帧**跟上,而且这一路上不能走过渡动画。
*
* 两点都和"滚动是连续手势"有关:
* 1. 不能用 `scheduleIndicatorSync()`(rAF 推后一帧)—— 快速滚动时底片会肉眼可见地落后;
* 滚动事件本身在布局之后派发,在这里同步量一次不会再插入一次强制布局。
* 2. 不能走 `.nav-indicator` 的 LAYOUT 弹簧(397ms)—— 那是"切换菜单项"的动画参数,
* 用在滚动上就成了"底片慢吞吞地追菜单项"。所以滚动期间挂 `is-following` 关掉过渡,
* 停手 140ms 后恢复,下一次点菜单项照样有滑动动画。
*/
const SCROLL_SETTLE_MS = 140;
let menuScroller: HTMLElement | null = null;
let scrollSettleTimer = 0;
const indicatorFollowing = ref(false);
function handleMenuScroll(): void {
if (!indicatorFollowing.value) indicatorFollowing.value = true;
if (scrollSettleTimer) window.clearTimeout(scrollSettleTimer);
scrollSettleTimer = window.setTimeout(() => {
scrollSettleTimer = 0;
indicatorFollowing.value = false;
}, SCROLL_SETTLE_MS);
syncIndicator();
}
/** /**
* 首次测量放在 `onMounted`:子组件先于父组件挂载完成,此刻 `.el-menu-item.is-active` * 首次测量放在 `onMounted`:子组件先于父组件挂载完成,此刻 `.el-menu-item.is-active`
* 已在文档里,量完再让浏览器首帧绘制 —— 指示器第一次出现就是正确位置,不会从左上角滑过去。 * 已在文档里,量完再让浏览器首帧绘制 —— 指示器第一次出现就是正确位置,不会从左上角滑过去。
@@ -125,6 +197,11 @@ function scheduleIndicatorSync(): void {
onMounted(() => { onMounted(() => {
syncIndicator(); syncIndicator();
const aside = document.querySelector<HTMLElement>('.aside.ch-nav'); const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
if (aside) {
// 菜单自己的滚动容器:翻菜单时底片必须跟着走(用户反馈的「上下滑动时项目选择块不移动」)
menuScroller = aside.querySelector<HTMLElement>('.menu');
menuScroller?.addEventListener('scroll', handleMenuScroll, { passive: true });
}
if (aside && typeof ResizeObserver !== 'undefined') { if (aside && typeof ResizeObserver !== 'undefined') {
asideObserver = new ResizeObserver(scheduleIndicatorSync); asideObserver = new ResizeObserver(scheduleIndicatorSync);
asideObserver.observe(aside); asideObserver.observe(aside);
@@ -134,6 +211,10 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
if (indicatorFrame) cancelAnimationFrame(indicatorFrame); if (indicatorFrame) cancelAnimationFrame(indicatorFrame);
indicatorFrame = 0; indicatorFrame = 0;
if (scrollSettleTimer) window.clearTimeout(scrollSettleTimer);
scrollSettleTimer = 0;
menuScroller?.removeEventListener('scroll', handleMenuScroll);
menuScroller = null;
asideObserver?.disconnect(); asideObserver?.disconnect();
asideObserver = null; asideObserver = null;
}); });
@@ -172,7 +253,7 @@ const activeMenu = computed(() => route.path);
/** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */ /** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */
const activeGroupTitle = computed(() => { const activeGroupTitle = computed(() => {
const group = menuGroups.find((item) => item.items.some((entry) => route.path === entry.path)); const group = menuGroups.value.find((item) => item.items.some((entry) => route.path === entry.path));
// 落在**一级项**(课表/作业/通知/成绩)上没有所属分组 —— 返回 null,别拿某个组的名字顶上去 // 落在**一级项**(课表/作业/通知/成绩)上没有所属分组 —— 返回 null,别拿某个组的名字顶上去
return group?.title ?? null; return group?.title ?? null;
}); });
@@ -190,7 +271,7 @@ const openedGroups = ref<string[]>(activeGroupTitle.value ? [activeGroupTitle.va
watch( watch(
() => route.path, () => route.path,
async (path) => { async (path) => {
const group = menuGroups.find((item) => item.items.some((entry) => path === entry.path)); const group = menuGroups.value.find((item) => item.items.some((entry) => path === entry.path));
if (!group) return; if (!group) return;
// 展开当前组(不主动关闭用户已展开的其它组) // 展开当前组(不主动关闭用户已展开的其它组)
await nextTick(); await nextTick();
@@ -215,7 +296,7 @@ function go(name: string): void {
/** 开发期自检:菜单路径必须存在于路由表中 */ /** 开发期自检:菜单路径必须存在于路由表中 */
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
const knownPaths = new Set(router.getRoutes().map((item) => item.path)); const knownPaths = new Set(router.getRoutes().map((item) => item.path));
for (const group of menuGroups) { for (const group of menuGroups.value) {
for (const item of group.items) { for (const item of group.items) {
if (!knownPaths.has(item.path)) { if (!knownPaths.has(item.path)) {
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`); console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
@@ -301,8 +382,16 @@ onUnmounted(() => {
<span class="brand-text">ClassHelper</span> <span class="brand-text">ClassHelper</span>
</div> </div>
<!-- 激活项的滑动指示器:单个底片在菜单项之间平移,位置由 syncIndicator() 量出来。 <!-- 激活项的滑动指示器:单个底片在菜单项之间平移,位置由 syncIndicator() 量出来。
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 --> 外层 .nav-indicator-clip 与 .menu 的视口重合(overflow:hidden),
<div class="nav-indicator" :style="indicatorStyle" aria-hidden="true" /> 把"跟着菜单滚出视口"的底片裁掉,不让它盖到品牌区/页脚上。
两层都 pointer-events:none —— 底片盖在菜单项上,否则会把点击吃掉。 -->
<div class="nav-indicator-clip" :style="clipStyle" aria-hidden="true">
<div
class="nav-indicator"
:class="{ 'is-following': indicatorFollowing }"
:style="indicatorStyle"
/>
</div>
<el-menu <el-menu
ref="menuRef" ref="menuRef"
:default-active="activeMenu" :default-active="activeMenu"
@@ -444,6 +533,18 @@ onUnmounted(() => {
position: relative; position: relative;
} }
/*
* 指示器的裁剪框:位置与尺寸每帧由 syncIndicator() 量成"与 .menu 视口重合"。
* 少了它,激活项被滚出菜单视口时底片会跑到品牌区/页脚上(用户反馈「上下滑动时
* 项目选择块不移动」的那次修复里一起加的)。
*/
.nav-indicator-clip {
position: absolute;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
/* /*
* 导航指示器:一个跟着激活项平移的底片,承载了原先"激活项底色 + 左侧强调条"两件事。 * 导航指示器:一个跟着激活项平移的底片,承载了原先"激活项底色 + 左侧强调条"两件事。
* *
@@ -459,7 +560,6 @@ onUnmounted(() => {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
z-index: 0;
border-radius: var(--ch-radius-control); border-radius: var(--ch-radius-control);
background: var(--ch-accent-soft); background: var(--ch-accent-soft);
/* 必须穿透:它盖在菜单项上,否则点击会被它吃掉 */ /* 必须穿透:它盖在菜单项上,否则点击会被它吃掉 */
@@ -472,6 +572,14 @@ onUnmounted(() => {
opacity var(--ch-dur-fast) var(--ch-ease-out); opacity var(--ch-dur-fast) var(--ch-ease-out);
} }
/*
* 菜单滚动期间(含停手后的 140ms)关掉过渡:滚动是连续手势,底片要"贴在"菜单项上一起走,
* 而不是每帧都启动一次 397ms 的弹簧去追。松手后恢复,点菜单项照样有滑动动画。
*/
.nav-indicator.is-following {
transition: none;
}
/* 左侧强调条(原有观感,现在跟着底片一起滑) */ /* 左侧强调条(原有观感,现在跟着底片一起滑) */
.nav-indicator::before { .nav-indicator::before {
content: ''; content: '';
@@ -1,5 +1,6 @@
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router'; import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router';
import { useAuthStore } from '../stores/auth.js'; import { useAuthStore } from '../stores/auth.js';
import { useUiStore } from '../stores/ui.js';
/** /**
* Electron 生产环境通过 file:// 加载页面,无法使用 history 模式, * Electron 生产环境通过 file:// 加载页面,无法使用 history 模式,
@@ -82,6 +83,18 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/settings/AboutView.vue'), component: () => import('../views/settings/AboutView.vue'),
meta: { title: '关于' }, meta: { title: '关于' },
}, },
{
/**
* 开发者(需求 10)。入口只在 `config.developerMode === true` 时出现在侧栏上,
* 但**路由始终注册**:关掉开关后侧栏项消失,已打开的页面靠一条守卫弹回「通用」,
* 不做成条件注册 —— 条件注册会让 `router.getRoutes()` 随配置变化,
* ClientLayout 的开发期自检与直接输 URL 的行为都会变得难以预期。
*/
path: 'settings/developer',
name: 'settings-developer',
component: () => import('../views/settings/DeveloperSettingsView.vue'),
meta: { title: '开发者' },
},
], ],
}, },
{ path: '/:pathMatch(.*)*', redirect: '/schedule' }, { path: '/:pathMatch(.*)*', redirect: '/schedule' },
@@ -99,6 +112,16 @@ router.beforeEach((to) => {
return true; return true;
} }
if (!auth.isAuthenticated) return { name: 'login' }; if (!auth.isAuthenticated) return { name: 'login' };
/**
* 开发者页(需求 10)只在开发者模式开启时可进:侧栏项本身也是条件显示的,
* 但配置可能是在**停留在这个页面时**被关掉的(「通用」页的开关),
* 所以需要这条守卫把它弹回「通用」,不让用户在看一个"已经不该存在"的页面。
*
* 这里读的是 ui store 里的状态(由 `ui.init()` 在首屏前从主进程配置恢复),
* 不额外调 `window.desktop.getConfig()` —— 导航守卫是同步高频路径,
* 每次跳转都跨一次 contextBridge 不值得。
*/
if (to.name === 'settings-developer' && !useUiStore().developerMode) return { name: 'settings-general' };
return true; return true;
}); });
@@ -13,6 +13,51 @@ import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
type RecoveryHandler = () => void; type RecoveryHandler = () => void;
/**
* 「本次离线从什么时候开始」的本地时间戳键(需求 11)。
*
* 为什么用 localStorage 而不是主进程配置(config.json):
* 它是一个**瞬时状态**,只描述"当前这一轮断网",一旦补弹完成(或进程重启后重新联网)就作废,
* 不需要跨进程读、也不需要跟着设备身份走。放 localStorage 有三点好处:
* - 写入发生在每次请求的**可达性回调**里(高频路径),localStorage 是同步内存写,
* 不会像 saveConfig 那样排一次主进程 IPC + 120ms 合并窗口;
* - 它是**渲染进程自己**的读-改-清闭环(bridge.ts 读它 → 补弹 → 清掉),不跨越 contextBridge;
* - 跨重启也够用:网页存储随 profile 持久化,补弹窗口本来就只关心"最近一次离线"。
*/
const OFFLINE_SINCE_KEY = 'classhelper.offlineSince';
/**
* 记下"本次离线从什么时候开始"(ISO 字符串)。
* 读不到 / 写不进(隐私模式、存储被清)时静默跳过 —— 补弹只是锦上添花,不该影响可达性上报。
*/
function markOfflineSince(): void {
try {
const now = new Date().toISOString();
localStorage.setItem(OFFLINE_SINCE_KEY, now);
console.info(`[离线] 已记录本次离线起点:${now}`);
} catch {
// 存储不可用:这次不补弹,但联网后的恢复流程照常
}
}
/** 读本次离线起点(ISO 字符串);没有记录(首次启动 / 一直在线)返回 null */
export function readOfflineSince(): string | null {
try {
return localStorage.getItem(OFFLINE_SINCE_KEY);
} catch {
return null;
}
}
/** 清掉离线起点:补弹完成后调用,避免同一次离线被重复补弹 */
export function clearOfflineSince(): void {
try {
localStorage.removeItem(OFFLINE_SINCE_KEY);
} catch {
// 清不掉也不影响功能:下一次"由可达变不可达"会覆盖它
}
}
/** /**
* 应用级状态:服务器地址、可达性、离线缓存统计、同步时间。 * 应用级状态:服务器地址、可达性、离线缓存统计、同步时间。
* 「联网后自动同步」的触发点就在 ping() 里:不可达 -> 可达 时通知所有订阅者刷新数据。 * 「联网后自动同步」的触发点就在 ping() 里:不可达 -> 可达 时通知所有订阅者刷新数据。
@@ -48,13 +93,22 @@ export const useAppStore = defineStore('app', () => {
recoveryHandlers.delete(handler); recoveryHandlers.delete(handler);
} }
/** 请求拦截器会把每次请求的结果回报过来,这里据此维护可达性 */ /**
* 请求拦截器会把每次请求的结果回报过来,这里据此维护可达性。
*
* 「由可达变不可达」的那一刻顺手记下离线起点(需求 11 的补弹基准):
* 只在**边界**上写,不在"一直不可达"的每次请求上重复覆盖,否则补弹窗口会被不停地
* 推迟到"最后一次失败",离线期间收到的通知就永远落在窗口之外了。
*/
setReachabilityReporter((reachable) => { setReachabilityReporter((reachable) => {
if (reachable && !serverReachable.value && initialized.value) { if (reachable && !serverReachable.value && initialized.value) {
serverReachable.value = true; serverReachable.value = true;
notifyRecovered(); notifyRecovered();
return; return;
} }
if (!reachable && serverReachable.value && initialized.value) {
markOfflineSince();
}
serverReachable.value = reachable; serverReachable.value = reachable;
}); });
@@ -68,6 +122,9 @@ export const useAppStore = defineStore('app', () => {
if (!serverReachable.value && initialized.value) notifyRecovered(); if (!serverReachable.value && initialized.value) notifyRecovered();
serverReachable.value = true; serverReachable.value = true;
} else { } else {
// 健康探针是机器**空闲时唯一**的可达性信号(没有任何业务请求在跑),
// 所以这条路径也要记离线起点,否则空闲断网期间收到的通知补不回来。
if (serverReachable.value && initialized.value) markOfflineSince();
serverReachable.value = false; serverReachable.value = false;
} }
} }
@@ -3,24 +3,47 @@ import { defineStore } from 'pinia';
import type { DesktopStoredConfig } from '../../types/desktop.js'; import type { DesktopStoredConfig } from '../../types/desktop.js';
/** /**
* 界面偏好(主题 / 侧边栏折叠)。 * 界面偏好(主题 / 侧边栏折叠 / 关怀模式)。
* *
* 持久化在主进程配置(DesktopStoredConfig.theme / sidebarCollapsed)里, * 持久化在主进程配置(DesktopStoredConfig.theme / sidebarCollapsed / careMode)里,
* 与灵动岛外观等设置同一套落盘机制;浏览器调试(无 window.desktop)回落 localStorage。 * 与灵动岛外观等设置同一套落盘机制;浏览器调试(无 window.desktop)回落 localStorage。
* 深色主题 = 在 <html> 上挂 .dark 类(Element Plus dark css-vars + styles/index.css 的 * 深色主题 = 在 <html> 上挂 .dark 类(Element Plus dark css-vars + styles/index.css 的
* html.dark 品牌变量),应用主题的全部入口都必须走 setTheme,保证类名与配置同步。 * html.dark 品牌变量),应用主题的全部入口都必须走 setTheme,保证类名与配置同步。
*
* 关怀模式 = 在 <html> 上挂 .care-mode 类(styles/index.css 末尾那个集中覆盖块),
* 只放大正文与控件字号,不动任何几何令牌 —— 理由写在那个块的注释里。
*/ */
const THEME_KEY = 'classhelper.theme'; const THEME_KEY = 'classhelper.theme';
const SIDEBAR_KEY = 'classhelper.sidebarCollapsed'; const SIDEBAR_KEY = 'classhelper.sidebarCollapsed';
const CARE_MODE_KEY = 'classhelper.careMode';
const DEVELOPER_MODE_KEY = 'classhelper.developerMode';
function applyThemeClass(theme: 'light' | 'dark'): void { function applyThemeClass(theme: 'light' | 'dark'): void {
document.documentElement.classList.toggle('dark', theme === 'dark'); document.documentElement.classList.toggle('dark', theme === 'dark');
} }
/**
* 关怀模式的类名开关。**只碰这一个类**:放大规则全在 CSS 里,
* JS 侧不逐个元素改字号(那样既漏元素又把主题与字号耦合在一起)。
*/
function applyCareModeClass(enabled: boolean): void {
document.documentElement.classList.toggle('care-mode', enabled);
}
export const useUiStore = defineStore('ui', () => { export const useUiStore = defineStore('ui', () => {
const theme = ref<'light' | 'dark'>('light'); const theme = ref<'light' | 'dark'>('light');
const sidebarCollapsed = ref(false); const sidebarCollapsed = ref(false);
const careMode = ref(false);
/**
* 开发者模式(需求 10)。
*
* 它既是主进程配置(`config.developerMode`,决定日志级别),也是**侧栏的一项** ——
* 所以必须放在 store 里:关掉开关时「开发者」菜单项要立刻消失、路由守卫也要立刻生效,
* 而「通用」页与 `ClientLayout.vue` 是两个不同的组件,靠各自的本地 ref 是传不过去的
* (此前"改完设置要重启客户端才看到"多半就是这个原因)。
*/
const developerMode = ref(false);
/** 启动时从主进程配置恢复(App.vue 的 boot 流程里调用,早于首屏渲染) */ /** 启动时从主进程配置恢复(App.vue 的 boot 流程里调用,早于首屏渲染) */
async function init(): Promise<void> { async function init(): Promise<void> {
@@ -29,10 +52,14 @@ export const useUiStore = defineStore('ui', () => {
if (config) { if (config) {
theme.value = config.theme === 'dark' ? 'dark' : 'light'; theme.value = config.theme === 'dark' ? 'dark' : 'light';
sidebarCollapsed.value = config.sidebarCollapsed === true; sidebarCollapsed.value = config.sidebarCollapsed === true;
applyCareMode(config.careMode === true);
developerMode.value = config.developerMode === true;
} }
} else { } else {
theme.value = localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light'; theme.value = localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light';
sidebarCollapsed.value = localStorage.getItem(SIDEBAR_KEY) === '1'; sidebarCollapsed.value = localStorage.getItem(SIDEBAR_KEY) === '1';
applyCareMode(localStorage.getItem(CARE_MODE_KEY) === '1');
developerMode.value = localStorage.getItem(DEVELOPER_MODE_KEY) === '1';
} }
applyThemeClass(theme.value); applyThemeClass(theme.value);
} }
@@ -66,8 +93,64 @@ export const useUiStore = defineStore('ui', () => {
void setSidebarCollapsed(!sidebarCollapsed.value); void setSidebarCollapsed(!sidebarCollapsed.value);
} }
return { theme, sidebarCollapsed, init, setTheme, toggleTheme, setSidebarCollapsed, toggleSidebar }; /**
* 应用关怀模式(需求 14):只挂类名 + 记状态,**不落盘**。
* 启动时由 `init()` 在首屏渲染前调用,所以刷新页面不会看到"先正常、后放大"的跳变。
*/
function applyCareMode(enabled: boolean): void {
careMode.value = enabled;
applyCareModeClass(enabled);
}
/**
* 切换关怀模式(「通用」设置页的开关):立刻生效并写回配置。
*
* 注意:用户明确开启时**不放大侧栏导轨**(见 styles/index.css 的 care-mode 块),
* 也**不做**任何 AI 助手按钮的联动 —— 本客户端目前没有助手入口,那条联动只存在于
* Web 管理端;这里不为了"看起来对称"去造一个入口。
*/
async function setCareMode(enabled: boolean): Promise<void> {
applyCareMode(enabled);
if (window.desktop) {
await window.desktop.saveConfig({ careMode: enabled }).catch(() => undefined);
} else {
localStorage.setItem(CARE_MODE_KEY, enabled ? '1' : '0');
}
}
/**
* 切换开发者模式(「通用」设置页的开关):立刻生效并写回配置。
*
* 打开之后有三件事同时发生,这里只负责**状态与落盘**:
* 1. 侧栏「设置」分组里出现「开发者」项(`ClientLayout.vue` 按 `ui.developerMode` 渲染);
* 2. `main/index.ts` 在下次启动时把日志级别调到 debug(`logger.setVerbose`)——
* 本次会话的日志级别**不重开**,避免开着日志复现问题时把级别改来改去;
* 3. 「通用」页上给出"debug 级日志已开启"的提示。
*/
async function setDeveloperMode(enabled: boolean): Promise<void> {
developerMode.value = enabled;
if (window.desktop) {
await window.desktop.saveConfig({ developerMode: enabled }).catch(() => undefined);
} else {
localStorage.setItem(DEVELOPER_MODE_KEY, enabled ? '1' : '0');
}
}
return {
theme,
sidebarCollapsed,
careMode,
developerMode,
init,
setTheme,
toggleTheme,
setSidebarCollapsed,
toggleSidebar,
applyCareMode,
setCareMode,
setDeveloperMode,
};
}); });
/** 供类型检查使用(仅引用类型) */ /** 供类型检查使用(仅引用类型) */
export type UiConfig = Pick<DesktopStoredConfig, 'theme' | 'sidebarCollapsed'>; export type UiConfig = Pick<DesktopStoredConfig, 'theme' | 'sidebarCollapsed' | 'careMode' | 'developerMode'>;
@@ -95,6 +95,12 @@
--el-border-color-lighter: rgba(0, 0, 0, 0.05); --el-border-color-lighter: rgba(0, 0, 0, 0.05);
--el-fill-color-blank: transparent; --el-fill-color-blank: transparent;
--el-font-size-base: 13px; --el-font-size-base: 13px;
/* 关怀模式的字号倍率(需求 14)。
* 平时恒为 1:它只作为「当前有没有放大」的单一事实来源,方便 JS 侧读取与断言,
* 真正的放大规则集中在文件末尾的 `html.care-mode` 块里(那里才是覆盖点,
* 且**刻意只动字号**——圆角 / 阴影 / 侧栏宽度等几何令牌一律不碰)。 */
--ch-font-scale: 1;
} }
/* ---------------------------------------------------------------- 深色主题 */ /* ---------------------------------------------------------------- 深色主题 */
@@ -946,3 +952,61 @@ textarea {
::view-transition-new(root) { ::view-transition-new(root) {
z-index: 2; z-index: 2;
} }
/* ------------------------------------------------------------ 关怀模式(需求 14)
*
* 给视力不佳的用户把**正文与控件**整体放大一档(约 +15%)。由 `stores/ui.ts` 的
* `applyCareMode()` 在首屏渲染前按 `config.careMode` 给 `<html>` 挂/摘 `.care-mode` 类。
*
* 三条刻意的边界,改这个块之前先读:
*
* 1. **只放大字号,不动几何。** 圆角(`--ch-radius-*`)、阴影(`--ch-shadow-*`)、
* 卡片内边距、侧栏宽度一律不碰 —— 放大字号已经会让文字换行,再动几何就是"另一个主题",
* 而且 `verify:desktop` 的多条断言量的是像素几何。
* 2. **侧栏导轨(`.ch-nav`)的条目字号不在这里放大。** §5 第 49 条那套"折叠成图标导轨"
* 的硬要求(导轨宽度、条目高度、折叠态文字必须既无宽度又透明)是按固定几何量出来的,
* 动导航条目字号会让折叠态文字重新占宽而断言变红。导航的可见标签保持原字号。
* 3. **用 `html.care-mode` 而不是 `:root`**:特异性 (0,1,1) 高于普通类选择器,
* 能稳定压过上面那些 `.page-title` / `.el-card__header` 等单类规则,不必写 `!important`。
* 深色主题无关 —— 这里一条颜色都没写。
*/
html.care-mode {
/* 放大倍率放在这里,下面的覆盖全部由它算出来 —— 想再调一档只改这一个数 */
--ch-font-scale: 1.15;
/* 覆盖 Element Plus 的基准字号:按钮、输入框、下拉、分页、消息框等控件都跟着它走
(基准值是 :root 里的 13px,见上面的 Element Plus 变量对齐) */
--el-font-size-base: calc(13px * var(--ch-font-scale));
}
/* 页面标题与副标题(`views/*` 的通用页头) */
html.care-mode .page-title {
font-size: calc(20px * var(--ch-font-scale));
}
html.care-mode .page-subtitle {
font-size: calc(12.5px * var(--ch-font-scale));
}
/* 卡片标题 / 折叠面板标题(这两处在上面被显式写成 13.5px,必须逐条覆盖) */
html.care-mode .el-card__header,
html.care-mode .el-collapse-item__header {
font-size: calc(13.5px * var(--ch-font-scale));
}
/* 表格:表头与单元格一起放大,行高由内容自然撑开(不设固定 height) */
html.care-mode .el-table {
font-size: calc(13px * var(--ch-font-scale));
}
html.care-mode .el-table th.el-table__cell {
font-size: calc(12.5px * var(--ch-font-scale));
}
/* 表单里 Element Plus 自己的标签/说明文字(默认跟着 --el-font-size-base,
* 但 `.el-form-item__label` 在部分页面被行内样式压过,这里统一一档更稳) */
html.care-mode .el-form-item__label,
html.care-mode .el-descriptions__label,
html.care-mode .el-descriptions__content,
html.care-mode .el-message-box__message {
font-size: calc(13px * var(--ch-font-scale));
}
@@ -2,10 +2,13 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'; import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { import {
NOTIFICATION_PRIORITIES,
PRIORITY_LABELS, PRIORITY_LABELS,
PRIORITY_TAG_TYPES, PRIORITY_TAG_TYPES,
dayKeyLocal,
formatDate, formatDate,
relativeTime, relativeTime,
shiftDayKey,
type NotificationDto, type NotificationDto,
type NotificationPriority, type NotificationPriority,
} from '@classhelper/shared'; } from '@classhelper/shared';
@@ -24,6 +27,85 @@ const visibleItems = computed(() =>
filter.value === 'unread' ? store.items.filter((item) => !item.read) : store.items, filter.value === 'unread' ? store.items.filter((item) => !item.read) : store.items,
); );
/* ------------------------------------------------------- 按天归档 / 按发布人 / 按类型 */
/**
* 三个筛选都在**已加载的列表**上做,不再请求服务端:
* 客户端是离线优先的(AGENTS.md §5 第 10 条),断网回退缓存时归档功能必须照常可用。
* 口径与服务端一致:按 `assignDate`(本地日期)分组 —— 用 createdAt 的 UTC 切片会把晚上
* 发出来的通知算到第二天(AGENTS.md §5 第 19 条)。
*/
const dayFilter = ref('');
const creatorFilter = ref('');
const priorityFilter = ref('');
/**
* 一条通知归到哪一天。
* `assignDate` 是服务端新加的字段,但**离线缓存里可能还留着加字段之前的旧数据**,
* 所以这里按 createdAt 的本地日期兜底,避免分组头显示成 "undefined"。
*/
function assignDay(item: NotificationDto): string {
return item.assignDate || dayKeyLocal(new Date(item.createdAt));
}
const dayOptions = computed(() => {
const map = new Map<string, number>();
for (const item of store.items) {
const day = assignDay(item);
map.set(day, (map.get(day) ?? 0) + 1);
}
return [...map.entries()]
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([date, count]) => ({ date, count }));
});
const creatorOptions = computed(() => {
const map = new Map<string, { name: string; count: number }>();
for (const item of store.items) {
const previous = map.get(item.createdBy);
map.set(item.createdBy, {
name: item.creator?.name ?? '未知',
count: (previous?.count ?? 0) + 1,
});
}
return [...map.entries()].map(([id, value]) => ({ id, ...value }));
});
const hasFilter = computed(() => Boolean(dayFilter.value || creatorFilter.value || priorityFilter.value));
const filteredItems = computed(() =>
visibleItems.value.filter(
(item) =>
(!dayFilter.value || assignDay(item) === dayFilter.value) &&
(!creatorFilter.value || item.createdBy === creatorFilter.value) &&
(!priorityFilter.value || item.priority === priorityFilter.value),
),
);
/** 按所属日期分块(同一天一块,块头显示"今天/昨天 + 日期 + 条数") */
const groupedItems = computed(() => {
const groups = new Map<string, NotificationDto[]>();
for (const item of filteredItems.value) {
const day = assignDay(item);
const list = groups.get(day);
if (list) list.push(item);
else groups.set(day, [item]);
}
return [...groups.entries()].map(([date, items]) => ({ date, items }));
});
function dayLabel(date: string): string {
const today = dayKeyLocal(new Date());
if (date === today) return '今天';
if (date === shiftDayKey(today, -1)) return '昨天';
return date;
}
function resetFilters(): void {
dayFilter.value = '';
creatorFilter.value = '';
priorityFilter.value = '';
}
async function openDetail(item: NotificationDto): Promise<void> { async function openDetail(item: NotificationDto): Promise<void> {
current.value = item; current.value = item;
detailVisible.value = true; detailVisible.value = true;
@@ -76,6 +158,7 @@ onUnmounted(() => {
<h2 class="page-title">通知</h2> <h2 class="page-title">通知</h2>
<p class="page-subtitle"> <p class="page-subtitle">
共 {{ store.items.length }} 条 · 未读 {{ store.unreadCount }} 条 共 {{ store.items.length }} 条 · 未读 {{ store.unreadCount }} 条
<span v-if="hasFilter">· 筛选后 {{ filteredItems.length }} 条</span>
<el-tag v-if="store.fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag> <el-tag v-if="store.fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p> </p>
</div> </div>
@@ -92,10 +175,59 @@ onUnmounted(() => {
</div> </div>
<el-card v-loading="store.loading" shadow="never"> <el-card v-loading="store.loading" shadow="never">
<el-empty v-if="visibleItems.length === 0" description="暂无通知" /> <!-- 按天归档 + 按发布人 + 按类型;都在本地已加载的列表上筛(离线也能用) -->
<ul v-else class="notice-list"> <div class="notice-filters">
<el-select v-model="dayFilter" placeholder="按天归档" clearable size="small" style="width: 150px">
<el-option
v-for="item in dayOptions"
:key="item.date"
:label="`${item.date}(${item.count})`"
:value="item.date"
/>
</el-select>
<el-select
v-model="creatorFilter"
placeholder="全部发布人"
clearable
size="small"
style="width: 150px"
>
<el-option
v-for="item in creatorOptions"
:key="item.id"
:label="`${item.name}(${item.count})`"
:value="item.id"
/>
</el-select>
<el-select
v-model="priorityFilter"
placeholder="全部类型"
clearable
size="small"
style="width: 130px"
>
<el-option
v-for="item in NOTIFICATION_PRIORITIES"
:key="item"
:label="PRIORITY_LABELS[item]"
:value="item"
/>
</el-select>
<el-button v-if="hasFilter" link type="primary" size="small" @click="resetFilters">
清除筛选
</el-button>
</div>
<el-empty v-if="filteredItems.length === 0" description="暂无通知" />
<div v-else class="notice-groups">
<section v-for="group in groupedItems" :key="group.date" class="notice-group">
<div class="notice-group__header">
<span>{{ dayLabel(group.date) }}</span>
<span class="text-muted">{{ group.date }} · {{ group.items.length }} 条</span>
</div>
<ul class="notice-list">
<li <li
v-for="item in visibleItems" v-for="item in group.items"
:key="item.id" :key="item.id"
class="notice-item" class="notice-item"
:class="{ unread: !item.read }" :class="{ unread: !item.read }"
@@ -123,6 +255,8 @@ onUnmounted(() => {
</div> </div>
</li> </li>
</ul> </ul>
</section>
</div>
</el-card> </el-card>
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '通知详情'"> <el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '通知详情'">
@@ -147,6 +281,30 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
.notice-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.notice-group + .notice-group {
margin-top: 10px;
}
.notice-group__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 6px 4px;
font-size: 12px;
font-weight: 600;
color: var(--ch-text-secondary);
border-bottom: 1px solid var(--ch-border);
}
.notice-list { .notice-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -0,0 +1,450 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { formatDate, relativeTime, type UpdateInfo } from '@classhelper/shared';
import type { ClientAgentStatus, DesktopAppInfo } from '../../../types/desktop.js';
import { useAppStore } from '../../stores/app.js';
import { useUiStore } from '../../stores/ui.js';
/**
* 开发者(需求 10)。
*
* 入口只在 `config.developerMode === true` 时出现在侧栏「设置」分组里(`ClientLayout.vue`),
* 路由守卫也会把"关掉开关后仍停留在这个页面"的用户弹回「通用」。
*
* 本页的定位是**把排查问题时需要的现场集中到一处**,所以刻意不做成"可操作的调试台":
* 每一项都是只读状态 + 一两个既有桥上的动作,数据全部来自 `window.desktop.*`
* 与既有 store,**不新增任何数据源、也不新增文件读写**。
*
* 几条取舍写在各区块的注释里,统一的原则是:
* 「关于」页面向的是所有用户(干净、能截图给管理员),本页面向维护者(原始值、路径、内部开关)。
*/
const appStore = useAppStore();
const ui = useUiStore();
const loading = ref(false);
const appInfo = ref<DesktopAppInfo | null>(null);
const agent = ref<ClientAgentStatus | null>(null);
/* ------------------------------------------------------------ 版本与平台 */
/** 版本、运行时与两个关键路径(用户数据目录 / 配置文件),排查"配置写哪去了"必看 */
async function loadAppInfo(): Promise<void> {
if (!window.desktop?.getAppInfo) return;
appInfo.value = await window.desktop.getAppInfo().catch(() => null);
}
/** 一次性把本页的所有状态拉新(刷新按钮与首次进页面共用) */
async function loadAll(): Promise<void> {
if (!window.desktop) return;
loading.value = true;
try {
await loadAppInfo();
await refreshAgent();
await loadIgnoredVersion();
await appStore.refreshCacheStats();
} finally {
loading.value = false;
}
}
/* ------------------------------------------------------------ 设备与上报 */
/**
* 服务端联动状态:设备尾号 / 最近上报 / 最近错误 / 目标版本 / 待安装版本 / 在线。
*
* 这一块与「通用」页的「服务端联动状态」是同一份数据 —— 有意保留的重叠:
* 排查上报问题时人在「通用」页看现象、在「开发者」页看原因(最近错误原文 + 设备尾号),
* 跳来跳去比重复几个字段更烦。
*/
const beating = ref(false);
const lastHeartbeatText = computed(() => {
const value = agent.value?.lastHeartbeatAt;
return value ? relativeTime(value) : '还没有上报过';
});
const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无');
async function refreshAgent(): Promise<void> {
if (!window.desktop?.getClientAgentStatus) return;
agent.value = await window.desktop.getClientAgentStatus().catch(() => null);
}
async function beatNow(): Promise<void> {
if (!window.desktop?.beatClientNow) return;
beating.value = true;
try {
// 手动上报是"立刻打一次",成功/失败都刷新状态,失败原因展示在「最近错误」里
agent.value = await window.desktop.beatClientNow();
if (agent.value?.lastError) ElMessage.warning(`上报未成功:${agent.value.lastError}`);
else ElMessage.success('已向服务端上报本机状态');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '上报失败,请检查网络与登录状态');
} finally {
beating.value = false;
}
}
/* ------------------------------------------------------------ 日志 */
/**
* 日志文件路径。
*
* 桥上没有"读日志内容"的接口(有意不加:日志是主进程的文件,给渲染进程一个读任意文件的
* 入口在安全边界上没有必要),所以这里只**提示路径** + 一个「打开日志目录」。
* 路径由用户数据目录拼出来:主进程的 logger 固定写在 `<userData>/logs/main.log`,
* 单文件 1MB 轮转(写满即改名为 main.old.log),所以"日志丢了"通常是轮转覆盖,不是没写。
*/
const logPathText = computed(() => {
const base = appInfo.value?.userDataPath;
if (!base) return 'logs/main.log(用户数据目录下)';
const sep = (appInfo.value?.platform ?? '').startsWith('win') ? '\\' : '/';
return `${base}${sep}logs${sep}main.log`;
});
async function openLogDir(): Promise<void> {
if (!window.desktop?.openLogDir) return;
const ok = await window.desktop.openLogDir();
if (!ok) ElMessage.warning('打开日志目录失败,请按上面的路径手动前往');
}
/* ------------------------------------------------------------ 更新 */
/**
* 更新检查。
*
* 「立即检查」走 `checkForUpdates(true)`:**强制绕过主进程那 30 分钟缓存**。
* 这是本页与「关于」页的区别 —— 那边用户点检查也可能是缓存结果(省流量),
* 这边维护者要的是"现在到底拿得到什么",所以按钮默认就是强制的。
*/
const checking = ref(false);
const updateInfo = ref<UpdateInfo | null>(null);
const ignoredUpdateVersion = ref('');
async function checkUpdate(force: boolean): Promise<void> {
if (!window.desktop?.checkForUpdates) return;
checking.value = true;
try {
updateInfo.value = await window.desktop.checkForUpdates(force);
} finally {
checking.value = false;
}
}
async function loadIgnoredVersion(): Promise<void> {
if (!window.desktop?.getConfig) return;
const stored = await window.desktop.getConfig().catch(() => null);
ignoredUpdateVersion.value = stored?.ignoredUpdateVersion ?? '';
}
async function ignoreVersion(): Promise<void> {
const version = updateInfo.value?.latestVersion;
if (!version || !window.desktop?.ignoreUpdateVersion) return;
await window.desktop.ignoreUpdateVersion(version);
ignoredUpdateVersion.value = version;
ElMessage.success(`已忽略 v${version}:启动时的自动检查不再提示它`);
}
/** 忽略是可逆的(排查线上"用户说不再提示"时经常要把它清掉),顺手给一个恢复入口 */
async function clearIgnoredVersion(): Promise<void> {
if (!window.desktop?.saveConfig) return;
const saved = await window.desktop.saveConfig({ ignoredUpdateVersion: '' });
ignoredUpdateVersion.value = saved.ignoredUpdateVersion;
ElMessage.success('已恢复该版本的更新提示');
}
/* ------------------------------------------------------------ 调试工具 */
const openingDevTools = ref(false);
/**
* 打开 DevTools(主进程 detached 独立窗口)。
* 冒烟验证模式下主进程会拒绝(多一个窗口会把自动化断言打乱),返回 false —— 这里如实提示。
*/
async function openDevTools(): Promise<void> {
if (!window.desktop?.openDevTools) return;
openingDevTools.value = true;
try {
const ok = await window.desktop.openDevTools();
if (ok) ElMessage.success('已在独立窗口打开开发者工具');
else ElMessage.warning('当前是冒烟验证模式,主进程不允许打开 DevTools');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '打开开发者工具失败');
} finally {
openingDevTools.value = false;
}
}
/* ------------------------------------------------------------ 离线缓存 */
/** 与「通用」页同一份缓存统计(这里再看一遍是为了"刷新/清空"不用跳页) */
const storeLabels: Record<string, string> = {
profile: '登录资料',
classes: '班级 / 学期',
schedules: '课表',
homeworks: '作业',
notifications: '通知',
grades: '成绩',
};
const cacheRows = computed(() =>
appStore.cacheStoreStats.map((item) => ({
label: storeLabels[item.store] ?? item.store,
store: item.store,
count: item.count,
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
})),
);
async function clearCache(): Promise<void> {
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
type: 'warning',
});
await appStore.clearCache();
ElMessage.success('离线缓存已清空');
}
onMounted(async () => {
await loadAll();
// 带出启动自动检查的结果(这次命中主进程缓存,不会真的再发一次请求)
void checkUpdate(false);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">开发者</h2>
<p class="page-subtitle">版本与平台、设备上报、日志、更新与调试工具</p>
</div>
<el-button :loading="loading" data-test="dev-refresh" @click="loadAll">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
<el-alert
class="mb-12"
type="warning"
:closable="false"
show-icon
title="仅供排查问题"
description="这一页显示的是原始状态与内部路径,不需要给日常使用者看。关闭「通用 → 开发者模式」后它就会从侧栏消失。"
/>
<el-row :gutter="12">
<el-col :xs="24" :md="12">
<!-- 版本与平台 -->
<el-card shadow="never">
<template #header><span>版本与平台</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="客户端版本">
{{ appInfo?.appVersion ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="运行时">
Electron {{ appInfo?.electron ?? '—' }} · Chromium {{ appInfo?.chrome ?? '—' }} · Node
{{ appInfo?.node ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="用户数据目录">
<span class="mono">{{ appInfo?.userDataPath ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="配置文件">
<span class="mono">{{ appInfo?.configPath ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="冒烟验证模式">
<el-tag :type="appInfo?.smokeTest ? 'warning' : 'info'" size="small" effect="plain">
{{ appInfo?.smokeTest ? '是(自动化验证中)' : '否' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 设备与上报 -->
<el-card shadow="never" class="mt-12">
<template #header><span>设备与上报</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="连接状态">
<el-tag :type="agent?.online ? 'success' : 'info'" size="small">
{{ agent?.online ? '在线(近期上报成功)' : '离线(等待上报)' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="设备标识尾号">
<span class="mono">{{ agent?.deviceKeyTail || '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="最近上报">{{ lastHeartbeatText }}</el-descriptions-item>
<el-descriptions-item label="服务端目标版本">
{{ agent?.updateTargetVersion || '无(保持当前版本)' }}
</el-descriptions-item>
<el-descriptions-item label="待安装版本">
{{ agent?.pendingUpdateVersion || '无' }}
</el-descriptions-item>
<el-descriptions-item label="最近错误">
<span :class="{ 'text-danger': lastErrorText !== '无' }">{{ lastErrorText }}</span>
</el-descriptions-item>
</el-descriptions>
<div class="toolbar mt-12">
<el-button type="primary" :loading="beating" data-test="dev-beat-now" @click="beatNow">
立即上报
</el-button>
<el-button :loading="loading" @click="loadAll">刷新状态</el-button>
</div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<!-- 日志 -->
<el-card shadow="never">
<template #header><span>日志</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="日志文件">
<span class="mono">{{ logPathText }}</span>
</el-descriptions-item>
<el-descriptions-item label="日志级别">
<el-tag :type="ui.developerMode ? 'success' : 'info'" size="small" effect="plain">
{{ ui.developerMode ? 'debug(开发者模式)' : 'info' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<p class="text-muted mt-12">
单个日志文件 1MB 轮转:写满后改名为 main.old.log,新内容写进 main.log。
所以"日志里找不到某次崩溃"多半是被轮转覆盖了,不是没写。
</p>
<!-- 不在这里用 <strong> 强调:中文整行的显示宽度是字符数的两倍,
而 Vue 模板里夹了行内元素的长句会被 Prettier 折行并插入空格,
渲染出来句子中间就多一个空格。要强调就单独起一行文字。 -->
<p v-if="ui.developerMode" class="text-muted">
debug 级日志已开启:级别在主进程启动时读取,改完开关后需重启客户端才生效。
</p>
<div class="toolbar mt-12">
<el-button data-test="dev-open-log-dir" @click="openLogDir">打开日志目录</el-button>
</div>
</el-card>
<!-- 更新 -->
<el-card shadow="never" class="mt-12">
<template #header><span>更新</span></template>
<div class="toolbar">
<el-button :loading="checking" data-test="dev-check-update" @click="checkUpdate(true)">
强制检查更新
</el-button>
<span class="text-muted">绕过主进程 30 分钟缓存,直接问 GitHub</span>
</div>
<el-descriptions :column="1" border size="small" class="mt-12">
<el-descriptions-item label="本次结果">
<template v-if="checking">正在检查…</template>
<template v-else-if="!updateInfo">尚未检查</template>
<template v-else-if="!updateInfo.ok">
<span class="text-muted">{{ updateInfo.error ?? '暂时无法检查更新' }}</span>
</template>
<template v-else-if="updateInfo.hasUpdate">
<el-tag size="small" type="success" effect="light">
有新版本 v{{ updateInfo.latestVersion }}
</el-tag>
<span class="text-muted">当前 v{{ updateInfo.currentVersion }}</span>
</template>
<template v-else>
<el-tag size="small" type="info" effect="plain">已是最新版本</el-tag>
<span class="text-muted">v{{ updateInfo.currentVersion }}</span>
</template>
</el-descriptions-item>
<el-descriptions-item label="检查时间">
{{ updateInfo?.checkedAt ? formatDate(updateInfo.checkedAt, true) : '—' }}
</el-descriptions-item>
<el-descriptions-item label="已忽略的版本">
<template v-if="ignoredUpdateVersion">
<span class="mono">v{{ ignoredUpdateVersion }}</span>
<el-button link type="primary" size="small" @click="clearIgnoredVersion">恢复提示</el-button>
</template>
<span v-else class="text-muted">无</span>
</el-descriptions-item>
</el-descriptions>
<div class="toolbar mt-12">
<el-button
:disabled="!updateInfo?.latestVersion"
data-test="dev-ignore-version"
@click="ignoreVersion"
>
不再提示该版本
</el-button>
<span class="text-muted">写入配置后,启动时的自动检查会跳过它</span>
</div>
</el-card>
<!-- 调试工具 -->
<el-card shadow="never" class="mt-12">
<template #header><span>调试工具</span></template>
<div class="toolbar">
<el-button :loading="openingDevTools" data-test="dev-open-devtools" @click="openDevTools">
打开 DevTools
</el-button>
<span class="text-muted">独立窗口;冒烟验证模式下不允许打开</span>
</div>
<el-alert class="mt-12" type="info" :closable="false" show-icon title="灵动岛诊断(命令行)">
<template #default>
<p class="diag-copy">灵动岛开合"闪一下"这类问题要的是整屏逐帧取证,界面上给不出这个按钮。</p>
<p class="diag-copy">跑冒烟时带上环境变量即可:</p>
<p class="diag-copy mono">ELECTRON_SMOKE_ISLAND_DIAG=1 pnpm verify:desktop</p>
<p class="diag-copy">它会在常规断言之外,对触摸/鼠标两种模式各跑一次收回动画的逐帧取证,</p>
<p class="diag-copy">帧图与逐帧数据落在 `.cache/island-closing-frames/`。</p>
</template>
</el-alert>
</el-card>
<!-- 离线缓存 -->
<el-card shadow="never" class="mt-12">
<template #header><span>离线缓存(IndexedDB)</span></template>
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
<el-table-column prop="label" label="数据类型" width="130" />
<el-table-column label="条目数" width="90">
<template #default="{ row }">{{ row.count }}</template>
</el-table-column>
<el-table-column label="最近更新" min-width="150">
<template #default="{ row }">{{ row.updatedAt }}</template>
</el-table-column>
</el-table>
<div class="toolbar mt-12">
<el-button @click="appStore.refreshCacheStats()">刷新统计</el-button>
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
/*
* 路径与版本号这类"要抄下来发给别人"的值统一用等宽字体(避免 0/O、1/l 看混),
* 并单独放开文本选中 —— body 上关掉了整页选中(更像原生应用),
* 但这一页的路径就是要让人复制的。
*
* 字体栈写死而不新建 `--ch-font-mono` 令牌:全局只有这一处在用,
* 为它加一个主题令牌(还得在 html.dark 里镜像一份)不划算。
*/
.mono {
font-family: 'Cascadia Mono', Consolas, 'Courier New', monospace;
font-size: 12.5px;
word-break: break-all;
user-select: text;
}
.mb-12 {
margin-bottom: 12px;
}
.text-danger {
color: var(--el-color-danger);
}
.diag-copy {
margin: 0 0 6px;
line-height: 1.6;
}
.diag-copy:last-child {
margin-bottom: 0;
}
</style>
@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { import {
HOMEWORK_PHRASE_DEFAULTS, HOMEWORK_PHRASE_DEFAULTS,
HOMEWORK_PHRASE_MAX_COUNT, HOMEWORK_PHRASE_MAX_COUNT,
HOMEWORK_PHRASE_MAX_LENGTH, HOMEWORK_PHRASE_MAX_LENGTH,
formatDate, formatDate,
relativeTime,
} from '@classhelper/shared'; } from '@classhelper/shared';
import type { ClientAgentStatus } from '../../../types/desktop.js';
import { pingHealth, setApiBaseUrl } from '../../api/http.js'; import { pingHealth, setApiBaseUrl } from '../../api/http.js';
import { normalizeServerUrl } from '../../config.js'; import { normalizeServerUrl } from '../../config.js';
import { useAppStore } from '../../stores/app.js'; import { useAppStore } from '../../stores/app.js';
@@ -14,6 +16,7 @@ import { useAuthStore } from '../../stores/auth.js';
import { useNotificationStore } from '../../stores/notifications.js'; import { useNotificationStore } from '../../stores/notifications.js';
import { useOnboardingStore } from '../../stores/onboarding.js'; import { useOnboardingStore } from '../../stores/onboarding.js';
import { useRealtimeStore } from '../../stores/realtime.js'; import { useRealtimeStore } from '../../stores/realtime.js';
import { useUiStore } from '../../stores/ui.js';
/** /**
* 通用设置:服务器连接、离线缓存与作业录入短语。 * 通用设置:服务器连接、离线缓存与作业录入短语。
@@ -24,6 +27,7 @@ const auth = useAuthStore();
const onboarding = useOnboardingStore(); const onboarding = useOnboardingStore();
const realtime = useRealtimeStore(); const realtime = useRealtimeStore();
const notifications = useNotificationStore(); const notifications = useNotificationStore();
const ui = useUiStore();
const serverInput = ref(appStore.serverUrl); const serverInput = ref(appStore.serverUrl);
const testing = ref(false); const testing = ref(false);
@@ -153,9 +157,193 @@ async function resetPhrases(): Promise<void> {
ElMessage.success('已恢复默认'); ElMessage.success('已恢复默认');
} }
/* ------------------------------------------------------------ 启动与更新 / 服务端联动 */
/**
* 开机自启与自动更新。
*
* 开机自启的"是否生效"以**系统登录项**为准(新增的 `getAutoLaunch` 桥读的是
* `app.getLoginItemSettings()`,不是配置文件里的值)—— 用户可能在任务管理器的「启动」页
* 里手动禁用了它,这时候配置写着 true 也不该显示成已开启。
*/
const autoLaunch = ref(false);
const autoUpdate = ref(true);
const savingLaunch = ref(false);
const savingUpdate = ref(false);
/** 服务端联动状态(心跳上报 / 目标版本 / 待安装版本) */
const agent = ref<ClientAgentStatus | null>(null);
const agentLoading = ref(false);
/** 本机客户端版本(与服务端下发的目标版本对照着看) */
const appVersion = ref('—');
/** 最近上报时间的相对描述(没有上报过就显示占位符) */
const lastHeartbeatText = computed(() => {
const value = agent.value?.lastHeartbeatAt;
if (!value) return '还没有上报过';
return relativeTime(value);
});
/** 服务端对本机版本的期望:空串表示"不需要更新" */
const updateTargetText = computed(() => {
const target = agent.value?.updateTargetVersion ?? '';
if (!target) return '无(保持当前版本)';
return target;
});
const pendingUpdateText = computed(() => {
const pending = agent.value?.pendingUpdateVersion ?? '';
if (!pending) return '无';
return `${pending}(已下载,空闲时自动安装)`;
});
const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无');
async function loadLaunchSettings(): Promise<void> {
if (!window.desktop?.getConfig) return;
const config = await window.desktop.getConfig();
autoUpdate.value = config.autoUpdate !== false;
autoLaunch.value = (await window.desktop.getAutoLaunch?.()) ?? config.autoLaunch === true;
const info = await window.desktop.getAppInfo?.().catch(() => null);
if (info?.appVersion) appVersion.value = info.appVersion;
}
async function toggleAutoLaunch(value: string | number | boolean): Promise<void> {
if (!window.desktop?.setAutoLaunch) return;
const wanted = value === true;
autoLaunch.value = wanted;
savingLaunch.value = true;
try {
const effective = await window.desktop.setAutoLaunch(wanted);
// 以系统实际生效值为准:写注册表失败时会与用户点的不一致,必须回显真实状态
autoLaunch.value = effective;
if (effective !== wanted) ElMessage.warning('系统未接受开机自启设置,请检查任务管理器里的启动项');
else ElMessage.success(wanted ? '已开启开机自启' : '已关闭开机自启');
} catch (error) {
autoLaunch.value = !wanted;
ElMessage.error(error instanceof Error ? error.message : '设置开机自启失败');
} finally {
savingLaunch.value = false;
}
}
async function toggleAutoUpdate(value: string | number | boolean): Promise<void> {
if (!window.desktop?.saveConfig) return;
const wanted = value === true;
autoUpdate.value = wanted;
savingUpdate.value = true;
try {
// 注意:必须传纯布尔值 —— 跨 contextBridge 传响应式对象会在进入 preload 前就被结构化克隆拒绝
const saved = await window.desktop.saveConfig({ autoUpdate: wanted });
autoUpdate.value = saved.autoUpdate !== false;
ElMessage.success(wanted ? '已开启自动更新' : '已关闭自动更新(仍可在「关于」页手动更新)');
} catch (error) {
autoUpdate.value = !wanted;
ElMessage.error(error instanceof Error ? error.message : '保存自动更新设置失败');
} finally {
savingUpdate.value = false;
}
}
/* ------------------------------------------------------------ 关怀模式 / 开发者模式 */
/**
* 关怀模式(需求 14):放大**正文与控件**字号。
*
* 这里只挂 `<html>.care-mode` 类并落盘,放大规则全在 `styles/index.css` 末尾那个集中覆盖块里
* (`--ch-font-scale` + 少数几个常用类),不在 JS 里逐个元素改字号。
*
* 开关直接读 ui store 的状态,**不用本地 ref 复制一份**:侧栏、路由守卫、主题页读的都是它,
* 复制就会出现"两个开关各说各话"。`ui.init()` 在首屏渲染前已从主进程配置恢复过。
*
* 关于"与助手联动":开启关怀模式会放大 AI 助手的按钮 —— 这条联动**只存在于 Web 管理端**,
* 本客户端目前没有助手入口,所以这里不放大任何按钮,也不为了"看起来对称"去造一个入口。
*/
const savingCareMode = ref(false);
async function toggleCareMode(value: string | number | boolean): Promise<void> {
const wanted = value === true;
savingCareMode.value = true;
try {
await ui.setCareMode(wanted);
ElMessage.success(wanted ? '已开启关怀模式:正文与控件字号已放大' : '已关闭关怀模式');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存关怀模式设置失败');
} finally {
savingCareMode.value = false;
}
}
/**
* 开发者模式(需求 10):显示调试页面与详细日志,仅排查问题时打开。
*
* 打开后侧栏「设置」分组里**立刻**多出「开发者」页(`ClientLayout.vue` 读的是同一个
* ui store 状态),下次启动时主进程会把日志级别调到 debug。
*/
const savingDeveloperMode = ref(false);
async function toggleDeveloperMode(value: string | number | boolean): Promise<void> {
const wanted = value === true;
savingDeveloperMode.value = true;
try {
await ui.setDeveloperMode(wanted);
ElMessage.success(wanted ? '已开启开发者模式:侧栏出现「开发者」页' : '已关闭开发者模式');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存开发者模式设置失败');
} finally {
savingDeveloperMode.value = false;
}
}
async function refreshAgentStatus(): Promise<void> {
if (!window.desktop?.getClientAgentStatus) return;
agentLoading.value = true;
try {
agent.value = await window.desktop.getClientAgentStatus();
} finally {
agentLoading.value = false;
}
}
async function beatNow(): Promise<void> {
if (!window.desktop?.beatClientNow) return;
agentLoading.value = true;
try {
// 手动上报是"立刻打一次",成功/失败都刷新状态,失败原因展示在「最近错误」里
agent.value = await window.desktop.beatClientNow();
if (agent.value?.lastError) ElMessage.warning(`上报未成功:${agent.value.lastError}`);
else ElMessage.success('已向服务端上报本机状态');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '上报失败,请检查网络与登录状态');
} finally {
agentLoading.value = false;
}
}
async function openLogDir(): Promise<void> {
if (!window.desktop?.openLogDir) return;
const ok = await window.desktop.openLogDir();
if (!ok) ElMessage.warning('打开日志目录失败,可在「关于」页复制诊断信息');
}
/** 状态里的相对时间要跟着走,否则挂着的页面会一直显示"刚刚" */
let statusTimer: ReturnType<typeof setInterval> | null = null;
onMounted(async () => { onMounted(async () => {
await appStore.refreshCacheStats(); await appStore.refreshCacheStats();
await loadPhrases(); await loadPhrases();
await loadLaunchSettings();
await refreshAgentStatus();
statusTimer = setInterval(() => {
void refreshAgentStatus();
}, 30_000);
});
onBeforeUnmount(() => {
if (statusTimer !== null) {
clearInterval(statusTimer);
statusTimer = null;
}
}); });
</script> </script>
@@ -238,6 +426,77 @@ onMounted(async () => {
<el-button @click="resetPhrases">恢复默认</el-button> <el-button @click="resetPhrases">恢复默认</el-button>
</div> </div>
</el-card> </el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>启动与更新</span></template>
<div class="switch-row">
<div class="switch-main">
<span>开机自启</span>
<p class="text-muted">登录 Windows 后自动启动 ClassHelper;教室机器建议打开。</p>
</div>
<el-switch
:model-value="autoLaunch"
:loading="savingLaunch"
@update:model-value="toggleAutoLaunch"
/>
</div>
<div class="switch-row">
<div class="switch-main">
<span>自动更新</span>
<p class="text-muted">
安装只在
<strong>不上课且窗口已隐藏 5 分钟后</strong>
进行,下载随时可能发生。
</p>
<p class="text-muted">服务端可以统一要求更新,关掉它只会把安装推迟到手动处理。</p>
</div>
<el-switch
:model-value="autoUpdate"
:loading="savingUpdate"
@update:model-value="toggleAutoUpdate"
/>
</div>
<div class="toolbar mt-12">
<el-button @click="openLogDir">打开日志目录</el-button>
<span class="text-muted">日志文件 main.log,排查问题时可发给管理员</span>
</div>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>显示与调试</span></template>
<div class="switch-row">
<div class="switch-main">
<span>关怀模式</span>
<p class="text-muted">
放大正文与控件字号(页面标题、表格、表单、卡片标题一起变大);圆角、投影与侧栏宽度等几何尺寸保持原样,侧栏图标导轨也不放大。
</p>
<p class="text-muted">
「开启后同时放大 AI 助手按钮」这一条只在 Web 管理端生效 —— 本客户端目前没有助手入口。
</p>
</div>
<el-switch
:model-value="ui.careMode"
:loading="savingCareMode"
data-test="care-mode-switch"
@update:model-value="toggleCareMode"
/>
</div>
<div class="switch-row">
<div class="switch-main">
<span>开发者模式</span>
<p class="text-muted">显示调试页面与详细日志,仅排查问题时打开。</p>
<p v-if="ui.developerMode" class="text-muted">
debug 级日志已开启;侧栏「设置 → 开发者」可查看版本、设备上报、更新检查、日志目录与 DevTools。
</p>
</div>
<el-switch
:model-value="ui.developerMode"
:loading="savingDeveloperMode"
data-test="developer-mode-switch"
@update:model-value="toggleDeveloperMode"
/>
</div>
</el-card>
</el-col> </el-col>
<el-col :xs="24" :md="12"> <el-col :xs="24" :md="12">
@@ -265,6 +524,35 @@ onMounted(async () => {
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。" description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
/> />
</el-card> </el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>服务端联动状态</span></template>
<p class="text-muted">
本机每 30 秒向服务端上报一次状态(是否在上课、当前版本、已下载的更新),
服务端据此下发统一更新与远控命令。本机标识尾号用于在服务端设备列表里核对这台电脑。
</p>
<el-descriptions class="mt-12" :column="1" border size="small">
<el-descriptions-item label="连接状态">
<el-tag :type="agent?.online ? 'success' : 'info'" size="small">
{{ agent?.online ? '已连接(近期上报成功)' : '未连接(等待上报)' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="本机版本">
{{ appVersion }}
</el-descriptions-item>
<el-descriptions-item label="设备标识尾号">
{{ agent?.deviceKeyTail || '—' }}
</el-descriptions-item>
<el-descriptions-item label="最近上报">{{ lastHeartbeatText }}</el-descriptions-item>
<el-descriptions-item label="服务端目标版本">{{ updateTargetText }}</el-descriptions-item>
<el-descriptions-item label="已下载待安装">{{ pendingUpdateText }}</el-descriptions-item>
<el-descriptions-item label="最近错误">{{ lastErrorText }}</el-descriptions-item>
</el-descriptions>
<div class="toolbar mt-12">
<el-button type="primary" :loading="agentLoading" @click="beatNow">立即上报</el-button>
<el-button :loading="agentLoading" @click="refreshAgentStatus">刷新状态</el-button>
</div>
</el-card>
</el-col> </el-col>
</el-row> </el-row>
</div> </div>
@@ -282,4 +570,29 @@ onMounted(async () => {
.ml-8 { .ml-8 {
margin-left: 8px; margin-left: 8px;
} }
/* 开关行:左说明右开关,说明长时自动换行(「自动更新」的提示是两行) */
.switch-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 8px 0;
}
.switch-row + .switch-row {
border-top: 1px solid var(--ch-border);
}
.switch-main {
display: flex;
flex-direction: column;
gap: 2px;
}
.switch-main p {
margin: 0;
font-size: 12.5px;
line-height: 1.5;
}
</style> </style>
+43
View File
@@ -9,6 +9,10 @@ import type {
IslandState, IslandState,
UpdateInfo, UpdateInfo,
} from '@classhelper/shared'; } from '@classhelper/shared';
import type { ClientAgentStatus } from '../main/client-agent.js';
/** 服务端联动状态:渲染进程要显示它,因此在这里转出(`desktop.d.ts` 是两端契约的单一入口) */
export type { ClientAgentStatus };
/** 作业页展示偏好(看板 / 列表,看板外观) */ /** 作业页展示偏好(看板 / 列表,看板外观) */
export interface HomeworkBoardSettings { export interface HomeworkBoardSettings {
@@ -66,6 +70,25 @@ export interface DesktopStoredConfig {
* 在「关于」页手动检查时仍会正常显示结果。 * 在「关于」页手动检查时仍会正常显示结果。
*/ */
ignoredUpdateVersion: string; ignoredUpdateVersion: string;
/**
* 本机设备标识(UUID):这台机器在服务端的身份。
*
* 服务端按它 upsert 设备记录(在线状态 / 待执行命令 / 已下载版本都挂在它上面),
* **首次读取配置时自动生成并落盘,之后永不变**;渲染进程只读,不允许改写。
*/
deviceKey: string;
/** 是否开机自启(登录 Windows 后静默启动,不弹主窗口) */
autoLaunch: boolean;
/** 是否接受服务端统一下发的自动更新(下载随时进行,安装只在空闲时) */
autoUpdate: boolean;
/** 开发者模式:把日志调到 debug 级,并在设置里显示「开发者」页 */
developerMode: boolean;
/** 关怀模式:整体放大正文与控件字号(不动圆角/阴影/侧栏几何) */
careMode: boolean;
/** 已下载待安装的更新版本(空串 = 无) */
pendingUpdateVersion: string;
/** 已下载待安装的安装包绝对路径(空串 = 无) */
pendingUpdateFile: string;
} }
export interface DesktopAppInfo { export interface DesktopAppInfo {
@@ -165,6 +188,26 @@ export interface DesktopBridge {
onIslandMarkRead(handler: (id: string) => void): void; onIslandMarkRead(handler: (id: string) => void): void;
/** 订阅"灵动岛点了标为已读(多条通知的整批)",一次把这一批都标记已读 */ /** 订阅"灵动岛点了标为已读(多条通知的整批)",一次把这一批都标记已读 */
onIslandMarkAllRead(handler: (ids: string[]) => void): void; onIslandMarkAllRead(handler: (ids: string[]) => void): void;
/* 开机自启 / 日志 / 服务端联动 */
/** 读当前是否已注册为开机自启(以系统登录项为准,不是配置文件里的值) */
getAutoLaunch(): Promise<boolean>;
/** 设置开机自启:写配置 + 同步系统登录项,返回生效后的值 */
setAutoLaunch(enabled: boolean): Promise<boolean>;
/** 打开日志目录(排查问题用;日志文件是 main.log) */
openLogDir(): Promise<boolean>;
/**
* 打开主窗口的开发者工具(detached 独立窗口)。
*
* 只在**非冒烟模式**下允许:冒烟验证会给窗口注入大量合成事件与截图,
* 多一个 DevTools 窗口会抢焦点、改变窗口尺寸,把那些断言打乱。
* 返回是否真的打开了(冒烟模式下返回 false)。
*/
openDevTools(): Promise<boolean>;
/** 读服务端联动状态(设置页「服务端联动状态」区块) */
getClientAgentStatus(): Promise<ClientAgentStatus>;
/** 立即向服务端上报一次,返回上报后的最新状态 */
beatClientNow(): Promise<ClientAgentStatus>;
} }
/** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */ /** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/server", "name": "@classhelper/server",
"version": "1.2.0", "version": "1.3.0",
"private": true, "private": true,
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问", "description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module", "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") role String @default("TEACHER")
/// 手机号(教师联系方式,可空) /// 手机号(教师联系方式,可空)
phone String @default("") phone String @default("")
/// 关怀模式(大字版界面):本人开关,Web 端与客户端各记一份
careMode Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -52,6 +54,8 @@ model User {
courses Course[] courses Course[]
createdHomeworks Homework[] @relation("HomeworkCreator") createdHomeworks Homework[] @relation("HomeworkCreator")
createdNotices Notification[] @relation("NotificationCreator") createdNotices Notification[] @relation("NotificationCreator")
/// 分配的权限组;**没有任何分组时**回落角色默认模板(见 shared 的 ROLE_DEFAULT_PERMISSIONS)
permissionGroups UserPermissionGroup[]
@@index([role]) @@index([role])
} }
@@ -240,6 +244,7 @@ model Class {
timeLayouts TimeLayout[] timeLayouts TimeLayout[]
integrationDevices IntegrationDevice[] integrationDevices IntegrationDevice[]
classIslandPushes ClassIslandPush[] classIslandPushes ClassIslandPush[]
clientDevices ClientDevice[]
@@index([teacherId]) @@index([teacherId])
@@index([enrollmentYear]) @@index([enrollmentYear])
@@ -353,6 +358,9 @@ model Notification {
title String title String
content String content String
priority String @default("NORMAL") priority String @default("NORMAL")
/// 发布所属日期(**本地日期 YYYY-MM-DD,不带时区**):按天归档的唯一依据。
/// 与 Homework.assignDate 同口径(shared 的 dayKeyLocal),**不要**用 createdAt 切 UTC。
assignDate String @default("")
createdBy String createdBy String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -362,6 +370,7 @@ model Notification {
reads NotificationRead[] reads NotificationRead[]
@@index([classId, createdAt]) @@index([classId, createdAt])
@@index([classId, assignDate])
} }
/// 通知已读记录 /// 通知已读记录
@@ -516,3 +525,174 @@ model ClassIslandPush {
@@index([classId, createdAt]) @@index([classId, createdAt])
@@index([deviceId, ackedAt]) @@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> { 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.grade.deleteMany();
await prisma.homeworkStatus.deleteMany(); await prisma.homeworkStatus.deleteMany();
await prisma.homework.deleteMany(); await prisma.homework.deleteMany();
@@ -117,6 +128,32 @@ async function main(): Promise<void> {
data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash }, 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,可在班级管理里重置) // 班级账号:班级码(ClassHelper 班级端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
// //
// 称呼按「入学年份 + 班号」生成:2026 级是高一(当前学年 2026-2027),2025 级是高二。 // 称呼按「入学年份 + 班号」生成:2026 级是高一(当前学年 2026-2027),2025 级是高二。
@@ -68,6 +68,18 @@ const COLUMNS = [
sql: `ALTER TABLE "User" ADD COLUMN "phone" TEXT NOT NULL DEFAULT ''`, sql: `ALTER TABLE "User" ADD COLUMN "phone" TEXT NOT NULL DEFAULT ''`,
note: '教师手机号', 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', method: 'POST',
body: { username: 'teacher2', password: 'teacher123' }, body: { username: 'teacher2', password: 'teacher123' },
}); });
const teacher2Token = teacher2Login.payload?.data?.token; let teacher2Token = teacher2Login.payload?.data?.token;
const teacher2Id = teacher2Login.payload?.data?.user?.id; let teacher2Id = teacher2Login.payload?.data?.user?.id;
const teacher1ClassIds = new Set(classes.map((item) => item.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 teacher2Classes = (await api('/classes', { token: teacher2Token })).payload?.data ?? [];
/** 本次验证临时创建的班级(收尾统一删除,不污染演示数据) */ /** 本次验证临时创建的班级(收尾统一删除,不污染演示数据) */
const probeClassIds = []; const probeClassIds = [];
@@ -1191,6 +1217,13 @@ async function main() {
if (subjectCourse) { if (subjectCourse) {
const otherCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken })) const otherCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken }))
.payload?.data?.find((item) => item.teacherId !== teacher2Id); .payload?.data?.find((item) => item.teacherId !== teacher2Id);
// 先确认"别人任教的科目"这个前提存在:缺了它,下面那条用例会因为"没带科目"
// 而被拒(403),看着是绿的,其实没验到"非任教科目"这条规则。
record(
'存在"别人任教科目"用于越权校验',
Boolean(otherCourse?.id),
`科目=${otherCourse?.name ?? '-'}`,
);
const foreignHomework = await api('/homeworks', { const foreignHomework = await api('/homeworks', {
method: 'POST', method: 'POST',
token: teacher2Token, 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. 权限隔离 // ---------------------------------------------------------------- 7. 权限隔离
if (foreignClass) { if (foreignClass) {
const studentCrossClass = await api(`/classes/${foreignClass.id}`, { token: studentToken }); 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}`, `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 学生名单导入(仅管理员)+ 数据清理 // 8.4 学生名单导入(仅管理员)+ 数据清理
const importedUsername = `IMP${Date.now().toString(36).slice(-6)}`.toUpperCase(); const importedUsername = `IMP${Date.now().toString(36).slice(-6)}`.toUpperCase();
// 学生导入模板已无"初始密码"列(学生没有账号属性);表头是「学号」 // 学生导入模板已无"初始密码"列(学生没有账号属性);表头是「学号」
@@ -2433,17 +3171,48 @@ async function main() {
// ---------------------------------------------------------------- 数据库管理(仅管理员) // ---------------------------------------------------------------- 数据库管理(仅管理员)
// 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。 // 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。
// 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。 // 注意:**不真跑切换**(会改写 .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 dbStatus = await api('/database/status', { token: adminToken });
const dbStatusData = dbStatus.payload?.data ?? {}; const dbStatusData = dbStatus.payload?.data ?? {};
const dbStatusTableNames = Array.isArray(dbStatusData.tables)
? dbStatusData.tables.map((item) => item.name)
: [];
record( record(
'数据库状态(管理员):已连接且返回 15 张表行数', '数据库状态(管理员):已连接且返回全部数据表行数',
dbStatus.status === 200 && dbStatus.status === 200 &&
dbStatusData.connected === true && dbStatusData.connected === true &&
Array.isArray(dbStatusData.tables) && 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} 表=${ `provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${
(dbStatusData.tables ?? []).length dbStatusTableNames.length
}`, }`,
); );
@@ -2556,13 +3325,17 @@ async function main() {
} catch { } catch {
snapshotParsed = null; snapshotParsed = null;
} }
const snapshotTableNames = Object.keys(snapshotParsed?.data ?? {});
record( record(
'导出快照(JSON 格式正确、15 张表、账号数 > 0)', '导出快照(JSON 格式正确、全部数据表、账号数 > 0)',
dbExport.status === 200 && dbExport.status === 200 &&
snapshotParsed?.format === 'classhelper-snapshot' && 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, (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 { disconnectPrisma, pingDatabase } from './lib/db.js';
import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js'; import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js';
import { logger } from './lib/logger.js'; import { logger } from './lib/logger.js';
import { refreshDeveloperMode } from './lib/settings.js';
import { resolveWebDistDir } from './lib/web-static.js'; import { resolveWebDistDir } from './lib/web-static.js';
import { initRealtime } from './realtime/socket.js'; import { initRealtime } from './realtime/socket.js';
import { startBackupScheduler } from './modules/database/database.service.js'; import { startBackupScheduler } from './modules/database/database.service.js';
@@ -51,6 +52,9 @@ async function prepareDatabase(): Promise<void> {
await prepareDatabase(); await prepareDatabase();
// 开发者模式的内存缓存:错误中间件每请求都要问一次,不可能为此查库(见 lib/settings.ts)
await refreshDeveloperMode();
const app = createApp(); const app = createApp();
const httpServer = createServer(app); const httpServer = createServer(app);
const io = initRealtime(httpServer); 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' | 'FORBIDDEN'
| 'NOT_FOUND' | 'NOT_FOUND'
| 'CONFLICT' | 'CONFLICT'
/** 课程:该班级已有同名课程(前端应复用 `details.courseId`,而不是另建一门) */
| 'COURSE_EXISTS'
/** 大模型:功能未开启 / 还没配置服务地址与密钥 */
| 'AI_NOT_CONFIGURED'
/** 大模型:调用超时或网络不可达 */
| 'AI_TIMEOUT'
/** 大模型:返回的内容不是可用的结构化动作(已记日志,不把原始回复回传给前端) */
| 'AI_INVALID_OUTPUT'
| 'TOO_MANY_REQUESTS' | 'TOO_MANY_REQUESTS'
/** 上课时间段发布紧急通知需要二次确认(前端据此弹出全屏警告 + 3 秒倒计时) */ /** 上课时间段发布紧急通知需要二次确认(前端据此弹出全屏警告 + 3 秒倒计时) */
| 'URGENT_DURING_CLASS' | 'URGENT_DURING_CLASS'
+4
View File
@@ -378,6 +378,8 @@ export interface NotificationLike {
priority: string; priority: string;
createdBy: string; createdBy: string;
createdAt: Date; createdAt: Date;
/** 通知所属日期(YYYY-MM-DD);老库可能为空串,DTO 层按 createdAt 本地日期兜底 */
assignDate?: string;
creator?: TeacherBriefLike | null; creator?: TeacherBriefLike | null;
reads?: NotificationReadLike[]; reads?: NotificationReadLike[];
} }
@@ -403,6 +405,8 @@ export function toNotificationDto(
priority: item.priority as NotificationPriority, priority: item.priority as NotificationPriority,
createdBy: item.createdBy, createdBy: item.createdBy,
createdAt: toIso(item.createdAt), createdAt: toIso(item.createdAt),
// 老库兜底:assignDate 为空时按 createdAt 的本地日期回退,前端不必处理空值
assignDate: item.assignDate || dayKeyLocal(item.createdAt),
creator: item.creator ? toTeacherBrief(item.creator) : null, creator: item.creator ? toTeacherBrief(item.creator) : null,
read: own ? true : false, read: own ? true : false,
readAt: toIsoOrNull(own?.readAt), 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` 是毕业归档的届别档案。 * `StudentClassTransfer` 存调班与转出历史;`ArchivedYear` 是毕业归档的届别档案。
* 历史上那两张 `ClassTeacher`(无科目的协作关系)与 `Enrollment`(与 Student.classId 双写) * 历史上那两张 `ClassTeacher`(无科目的协作关系)与 `Enrollment`(与 Student.classId 双写)
* 已随 2026-10-06 的重构删除。 * 已随 2026-10-06 的重构删除。
*
* 2026-10-10 新增:权限组(`PermissionGroup` / `PermissionGroupItem` / `UserPermissionGroup`)、
* 全局设置 `AppSetting`(大模型配置与开发者模式开关)、客户端设备与远控
* (`ClientDevice` / `ClientCommand`)、客户端安装包缓存 `ClientRelease`、大模型审计 `AiAudit`。
* **加新表一定同步这里**,否则备份/换库会静默丢表。
*/ */
export const SNAPSHOT_TABLES = [ export const SNAPSHOT_TABLES = [
'User', 'User',
'PermissionGroup',
'PermissionGroupItem',
'UserPermissionGroup',
'AppSetting',
'ArchivedYear', 'ArchivedYear',
'Class', 'Class',
'Student', 'Student',
@@ -43,6 +52,10 @@ export const SNAPSHOT_TABLES = [
'Grade', 'Grade',
'IntegrationDevice', 'IntegrationDevice',
'ClassIslandPush', 'ClassIslandPush',
'ClientDevice',
'ClientCommand',
'ClientRelease',
'AiAudit',
] as const; ] as const;
export type SnapshotTable = (typeof SNAPSHOT_TABLES)[number]; export type SnapshotTable = (typeof SNAPSHOT_TABLES)[number];
+67 -10
View File
@@ -1,12 +1,17 @@
import type { Request, RequestHandler } from 'express'; 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 { prisma } from '../lib/db.js';
import { ApiError } from '../lib/http.js'; import { ApiError } from '../lib/http.js';
import { loadEffectivePermissions } from '../lib/permissions.js';
import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js'; import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js';
/** /**
* 认证中间件:解析 Bearer token 并回查数据库, * 认证中间件:解析 Bearer token 并回查数据库,
* 保证角色 / 班级变更(例如管理员换了班主任)立即生效,不被过期载荷影响。 * 保证角色 / 班级变更(例如管理员换了班主任)立即生效,不被过期载荷影响。
*
* 2026-10-10 起**顺带装载生效权限集合**(`req.auth.permissions`):
* 角色判据换成权限键判据(`requirePermission`),角色只用来决定"没有分组时的默认模板"。
*/ */
export function authenticate(): RequestHandler { export function authenticate(): RequestHandler {
return async (req, _res, next) => { return async (req, _res, next) => {
@@ -24,9 +29,7 @@ export function authenticate(): RequestHandler {
}); });
if (!record) throw ApiError.unauthorized('班级不存在或已被删除'); if (!record) throw ApiError.unauthorized('班级不存在或已被删除');
req.auth = { const user: TokenPayload = {
token,
user: {
sub: record.id, sub: record.id,
username: record.code, username: record.code,
name: record.name, name: record.name,
@@ -34,8 +37,8 @@ export function authenticate(): RequestHandler {
classId: record.id, classId: record.id,
classSession: true, classSession: true,
classCode: record.code, classCode: record.code,
},
}; };
req.auth = { token, user, permissions: await loadEffectivePermissions(user) };
next(); next();
return; return;
} }
@@ -47,16 +50,14 @@ export function authenticate(): RequestHandler {
}); });
if (!user) throw ApiError.unauthorized('账号不存在或已被删除'); if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
req.auth = { const authUser: TokenPayload = {
token,
user: {
sub: user.id, sub: user.id,
username: user.username, username: user.username,
name: user.name, name: user.name,
role: user.role as UserRole, role: user.role as UserRole,
classId: null, classId: null,
},
}; };
req.auth = { token, user: authUser, permissions: await loadEffectivePermissions(authUser) };
next(); next();
} catch (error) { } catch (error) {
next(error); next(error);
@@ -64,7 +65,7 @@ export function authenticate(): RequestHandler {
}; };
} }
/** 角色守卫 */ /** 角色守卫(仍保留:少数场景要按角色而不是按权限键判断,如班级端专属入口) */
export function requireRole(...roles: UserRole[]): RequestHandler { export function requireRole(...roles: UserRole[]): RequestHandler {
return (req, _res, next) => { return (req, _res, next) => {
const user = req.auth?.user; 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 */ /** 取出当前登录用户,未认证时抛 401 */
export function getAuthUser(req: Request): TokenPayload { export function getAuthUser(req: Request): TokenPayload {
if (!req.auth?.user) throw ApiError.unauthorized(); if (!req.auth?.user) throw ApiError.unauthorized();
return req.auth.user; 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 type { ApiErrorResponse } from '@classhelper/shared';
import { ApiError } from '../lib/http.js'; import { ApiError } from '../lib/http.js';
import { logger } from '../lib/logger.js'; import { logger } from '../lib/logger.js';
import { isDeveloperMode } from '../lib/settings.js';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
function sendError( function sendError(
@@ -36,7 +37,21 @@ export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => {
if (error instanceof ApiError) { if (error instanceof ApiError) {
if (error.status >= 500) logger.error(error.message, error.details); 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; 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 { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js'; import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.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 { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
@@ -25,13 +25,18 @@ router.use(authenticate());
*/ */
/** GET /api/archives - 届别列表 */ /** GET /api/archives - 届别列表 */
router.get('/', requireRole('ADMIN'), validate({ query: listArchivesQuerySchema }), async (req, res) => { router.get(
'/',
requirePermission('archive:manage'),
validate({ query: listArchivesQuerySchema }),
async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
sendOk(res, await archiveService.listArchivedYears(user), '获取届别列表成功'); sendOk(res, await archiveService.listArchivedYears(user), '获取届别列表成功');
}); },
);
/** GET /api/archives/summary - 归档页顶部汇总 + 可归档的届别建议 */ /** GET /api/archives/summary - 归档页顶部汇总 + 可归档的届别建议 */
router.get('/summary', requireRole('ADMIN'), async (req, res) => { router.get('/summary', requirePermission('archive:manage'), async (req, res) => {
sendOk( sendOk(
res, res,
{ {
@@ -43,16 +48,21 @@ router.get('/summary', requireRole('ADMIN'), async (req, res) => {
}); });
/** GET /api/archives/:id - 某届详情(班级 + 毕业生 + 转出的学生) */ /** GET /api/archives/:id - 某届详情(班级 + 毕业生 + 转出的学生) */
router.get('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => { router.get(
'/:id',
requirePermission('archive:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req); const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await archiveService.getArchivedYearDetail(user, id), '获取届别详情成功'); sendOk(res, await archiveService.getArchivedYearDetail(user, id), '获取届别详情成功');
}); },
);
/** GET /api/archives/:id/classes/:classId/content - 归档班级的作业与通知(只读) */ /** GET /api/archives/:id/classes/:classId/content - 归档班级的作业与通知(只读) */
router.get( router.get(
'/:id/classes/:classId/content', '/:id/classes/:classId/content',
requireRole('ADMIN'), requirePermission('archive:manage'),
validate({ params: archiveClassParamSchema, query: archivedContentQuerySchema }), validate({ params: archiveClassParamSchema, query: archivedContentQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -67,19 +77,29 @@ router.get(
); );
/** POST /api/archives - 执行毕业归档(按届别整届归档) */ /** POST /api/archives - 执行毕业归档(按届别整届归档) */
router.post('/', requireRole('ADMIN'), validate({ body: createArchiveSchema }), async (req, res) => { router.post(
'/',
requirePermission('archive:manage'),
validate({ body: createArchiveSchema }),
async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
const result = await archiveService.createArchive(user, validatedBody(req)); const result = await archiveService.createArchive(user, validatedBody(req));
sendCreated(res, result, `${result.name} 已归档,共 ${result.classCount} 个班级`); sendCreated(res, result, `${result.name} 已归档,共 ${result.classCount} 个班级`);
}); },
);
/** DELETE /api/archives/:id - 撤销归档(管理员误操作的退路,不删任何数据) */ /** DELETE /api/archives/:id - 撤销归档(管理员误操作的退路,不删任何数据) */
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => { router.delete(
'/:id',
requirePermission('archive:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req); const { id } = validatedParams<{ id: string }>(req);
await archiveService.deleteArchive(user, id); await archiveService.deleteArchive(user, id);
sendOk(res, { id }, '已撤销归档,班级与学生回到在读状态'); sendOk(res, { id }, '已撤销归档,班级与学生回到在读状态');
}); },
);
export const archivesModule = defineModule({ export const archivesModule = defineModule({
name: 'archives', name: 'archives',
@@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated } from '../../lib/http.js'; 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 { validate } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { createCallSchema, type CreateCallInput } from './calls.schemas.js'; import { createCallSchema, type CreateCallInput } from './calls.schemas.js';
@@ -13,7 +13,7 @@ router.use(authenticate());
* POST /api/calls - 叫人(老师点名让学生过来) * POST /api/calls - 叫人(老师点名让学生过来)
* ClassHelper 班级端会在灵动岛上立即弹出"请 XXX 同学找 XXX 老师",无论是否在上课。 * 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); const user = getAuthUser(req);
sendCreated(res, await callService.createCall(user, req.body as CreateCallInput), '已通知该学生'); sendCreated(res, await callService.createCall(user, req.body as CreateCallInput), '已通知该学生');
}); });
@@ -1,7 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js'; import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js'; import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { import {
validate, validate,
validatedBody, validatedBody,
@@ -56,7 +56,7 @@ router.get('/', validate({ query: listClassesQuerySchema }), async (req, res) =>
*/ */
router.post( router.post(
'/promote', '/promote',
requireRole('ADMIN'), requirePermission('class:manage'),
validate({ body: promoteClassesSchema }), validate({ body: promoteClassesSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -73,7 +73,7 @@ router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
}); });
/** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */ /** 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); const user = getAuthUser(req);
sendCreated(res, await classService.createClass(user, req.body as CreateClassInput), '班级创建成功'); 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 - 编辑班级(年级 / 届别 / 教学周数),仅管理员 */ /** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),仅管理员 */
router.patch( router.patch(
'/:id', '/:id',
requireRole('ADMIN'), requirePermission('class:manage'),
validate({ params: idParamSchema, body: updateClassSchema }), validate({ params: idParamSchema, body: updateClassSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -93,7 +93,7 @@ router.patch(
/** DELETE /api/classes/:id - 删除班级 */ /** DELETE /api/classes/:id - 删除班级 */
router.delete( router.delete(
'/:id', '/:id',
requireRole('ADMIN'), requirePermission('class:manage'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -109,7 +109,7 @@ router.delete(
*/ */
router.patch( router.patch(
'/:id/class-account', '/:id/class-account',
requireRole('ADMIN'), requirePermission('class:manage'),
validate({ params: idParamSchema, body: updateClassAccountSchema }), validate({ params: idParamSchema, body: updateClassAccountSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -128,7 +128,7 @@ router.patch(
*/ */
router.patch( router.patch(
'/:id/head-teacher', '/:id/head-teacher',
requireRole('ADMIN'), requirePermission('class:manage'),
validate({ params: idParamSchema, body: assignHeadTeacherSchema }), validate({ params: idParamSchema, body: assignHeadTeacherSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -147,7 +147,7 @@ router.patch(
*/ */
router.put( router.put(
'/:id/subject-teachers', '/:id/subject-teachers',
requireRole('ADMIN'), requirePermission('class:manage'),
validate({ params: idParamSchema, body: assignSubjectTeachersSchema }), validate({ params: idParamSchema, body: assignSubjectTeachersSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -244,7 +244,7 @@ router.get('/:id/students', validate({ params: idParamSchema }), async (req, res
/** POST /api/classes/:id/students - 把学生加入班级(按学号,已存在则转入并留调班痕迹) */ /** POST /api/classes/:id/students - 把学生加入班级(按学号,已存在则转入并留调班痕迹) */
router.post( router.post(
'/:id/students', '/:id/students',
requireRole('ADMIN', 'TEACHER'), requirePermission('roster:manage'),
validate({ params: idParamSchema, body: addStudentSchema }), validate({ params: idParamSchema, body: addStudentSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -256,7 +256,7 @@ router.post(
/** DELETE /api/classes/:id/students/:studentId - 移出学生(回到「未分班」,名单记录保留) */ /** DELETE /api/classes/:id/students/:studentId - 移出学生(回到「未分班」,名单记录保留) */
router.delete( router.delete(
'/:id/students/:studentId', '/:id/students/:studentId',
requireRole('ADMIN', 'TEACHER'), requirePermission('roster:manage'),
validate({ params: classStudentParamSchema }), validate({ params: classStudentParamSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); 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 { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js'; import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.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 { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
@@ -26,7 +26,7 @@ router.get('/', validate({ query: listCoursesQuerySchema }), async (req, res) =>
/** POST /api/courses - 新增课程 */ /** POST /api/courses - 新增课程 */
router.post( router.post(
'/', '/',
requireRole('ADMIN', 'TEACHER'), requirePermission('schedule:write'),
validate({ body: createCourseSchema }), validate({ body: createCourseSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -37,7 +37,7 @@ router.post(
/** PATCH /api/courses/:id - 修改课程 */ /** PATCH /api/courses/:id - 修改课程 */
router.patch( router.patch(
'/:id', '/:id',
requireRole('ADMIN', 'TEACHER'), requirePermission('schedule:write'),
validate({ params: idParamSchema, body: updateCourseSchema }), validate({ params: idParamSchema, body: updateCourseSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -49,7 +49,7 @@ router.patch(
/** DELETE /api/courses/:id - 删除课程 */ /** DELETE /api/courses/:id - 删除课程 */
router.delete( router.delete(
'/:id', '/:id',
requireRole('ADMIN', 'TEACHER'), requirePermission('schedule:write'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); 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 { assertCanManageSchedule, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.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> { export async function createCourse(user: TokenPayload, input: CreateCourseInput): Promise<CourseDto> {
await assertCanManageSchedule(user, input.classId); 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({ const created = await prisma.course.create({
data: { name: input.name, classId: input.classId, teacherId: user.sub }, data: { name: input.name, classId: input.classId, teacherId: user.sub },
include: { teacher: teacherSelect }, include: { teacher: teacherSelect },
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js'; 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 { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
@@ -22,7 +22,7 @@ import * as databaseService from './database.service.js';
/** /**
* 数据库管理(仅管理员):状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。 * 数据库管理(仅管理员):状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。
* *
* 改动数据库属于最高危操作,全部接口挂在 requireRole('ADMIN') 之下; * 改动数据库属于最高危操作,全部接口挂在 requirePermission('database:manage') 之下;
* 前端入口同样只对 ADMIN 显示(Web 端「数据库管理」页)。 * 前端入口同样只对 ADMIN 显示(Web 端「数据库管理」页)。
* *
* 一键切换是异步任务:POST /switch 返回 jobId,前端轮询 GET /switch/jobs/:id 展示步骤进度。 * 一键切换是异步任务:POST /switch 返回 jobId,前端轮询 GET /switch/jobs/:id 展示步骤进度。
@@ -31,7 +31,7 @@ import * as databaseService from './database.service.js';
const router = Router(); const router = Router();
router.use(authenticate()); router.use(authenticate());
router.use(requireRole('ADMIN')); router.use(requirePermission('database:manage'));
/** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */ /** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */
router.get('/status', async (_req, res) => { router.get('/status', async (_req, res) => {
@@ -1,7 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js'; import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.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 { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
@@ -56,7 +56,7 @@ router.get(
/** GET /api/grades/stats?classId=&courseId=&examName= - 成绩统计(图表) */ /** GET /api/grades/stats?classId=&courseId=&examName= - 成绩统计(图表) */
router.get( router.get(
'/stats', '/stats',
requireRole('ADMIN', 'TEACHER'), requirePermission('grade:read'),
validate({ query: statsQuerySchema }), validate({ query: statsQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -68,7 +68,7 @@ router.get(
/** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */ /** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */
router.get( router.get(
'/', '/',
requireRole('ADMIN', 'TEACHER'), requirePermission('grade:read'),
validate({ query: listGradesQuerySchema }), validate({ query: listGradesQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -83,7 +83,7 @@ router.get(
); );
/** POST /api/grades - 录入单条成绩(仅自己任教科目) */ /** 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); const user = getAuthUser(req);
sendCreated( sendCreated(
res, res,
@@ -100,7 +100,7 @@ router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSc
*/ */
router.patch( router.patch(
'/levels', '/levels',
requireRole('ADMIN', 'TEACHER'), requirePermission('grade:write'),
validate({ body: updateGradeLevelsSchema }), validate({ body: updateGradeLevelsSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -112,7 +112,7 @@ router.patch(
/** POST /api/grades/bulk - 批量录入 / 导入成绩 */ /** POST /api/grades/bulk - 批量录入 / 导入成绩 */
router.post( router.post(
'/bulk', '/bulk',
requireRole('ADMIN', 'TEACHER'), requirePermission('grade:write'),
validate({ body: bulkCreateGradeSchema }), validate({ body: bulkCreateGradeSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -124,7 +124,7 @@ router.post(
/** PATCH /api/grades/:id - 修改成绩(含单个改等级) */ /** PATCH /api/grades/:id - 修改成绩(含单个改等级) */
router.patch( router.patch(
'/:id', '/:id',
requireRole('ADMIN', 'TEACHER'), requirePermission('grade:write'),
validate({ params: idParamSchema, body: updateGradeSchema }), validate({ params: idParamSchema, body: updateGradeSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -136,7 +136,7 @@ router.patch(
/** DELETE /api/grades/:id - 删除成绩 */ /** DELETE /api/grades/:id - 删除成绩 */
router.delete( router.delete(
'/:id', '/:id',
requireRole('ADMIN', 'TEACHER'), requirePermission('grade:write'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -1,16 +1,18 @@
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js'; import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.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 { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
bulkCreateHomeworkSchema,
createHomeworkSchema, createHomeworkSchema,
listHomeworkDaysQuerySchema, listHomeworkDaysQuerySchema,
listHomeworksQuerySchema, listHomeworksQuerySchema,
updateHomeworkSchema, updateHomeworkSchema,
updateHomeworkStatusSchema, updateHomeworkStatusSchema,
updateHomeworkSubmissionsSchema, updateHomeworkSubmissionsSchema,
type BulkCreateHomeworkInput,
type CreateHomeworkInput, type CreateHomeworkInput,
type ListHomeworkDaysInput, type ListHomeworkDaysInput,
type UpdateHomeworkInput, 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 - 修改作业 */ /** PATCH /api/homeworks/:id - 修改作业 */
router.patch( router.patch(
'/:id', '/:id',
requireRole('ADMIN', 'TEACHER'), requirePermission('homework:write'),
validate({ params: idParamSchema, body: updateHomeworkSchema }), validate({ params: idParamSchema, body: updateHomeworkSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -90,7 +114,7 @@ router.patch(
/** DELETE /api/homeworks/:id - 删除作业 */ /** DELETE /api/homeworks/:id - 删除作业 */
router.delete( router.delete(
'/:id', '/:id',
requireRole('ADMIN', 'TEACHER'), requirePermission('homework:write'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -37,6 +37,26 @@ export const createHomeworkSchema = z.object({
assignDate: dayKeySchema.nullish(), 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({ export const updateHomeworkSchema = z.object({
courseId: z.string().min(1).nullish(), courseId: z.string().min(1).nullish(),
/** 改作业所属日期(把作业挪到另一天) */ /** 改作业所属日期(把作业挪到另一天) */
@@ -62,6 +82,7 @@ export const updateHomeworkSubmissionsSchema = z.object({
export type ListHomeworkDaysInput = z.infer<typeof listHomeworkDaysQuerySchema>; export type ListHomeworkDaysInput = z.infer<typeof listHomeworkDaysQuerySchema>;
export type CreateHomeworkInput = z.infer<typeof createHomeworkSchema>; export type CreateHomeworkInput = z.infer<typeof createHomeworkSchema>;
export type BulkCreateHomeworkInput = z.infer<typeof bulkCreateHomeworkSchema>;
export type UpdateHomeworkInput = z.infer<typeof updateHomeworkSchema>; export type UpdateHomeworkInput = z.infer<typeof updateHomeworkSchema>;
export type UpdateHomeworkStatusInput = z.infer<typeof updateHomeworkStatusSchema>; export type UpdateHomeworkStatusInput = z.infer<typeof updateHomeworkStatusSchema>;
export type UpdateHomeworkSubmissionsInput = z.infer<typeof updateHomeworkSubmissionsSchema>; export type UpdateHomeworkSubmissionsInput = z.infer<typeof updateHomeworkSubmissionsSchema>;
@@ -2,6 +2,9 @@ import {
SOCKET_EVENTS, SOCKET_EVENTS,
dayKeyLocal, dayKeyLocal,
shiftDayKey, shiftDayKey,
type BulkCreateHomeworkRequest,
type BulkPublishClassResult,
type BulkPublishResult,
type HomeworkDaysDto, type HomeworkDaysDto,
type HomeworkDto, type HomeworkDto,
type HomeworkStatusDto, type HomeworkStatusDto,
@@ -15,9 +18,11 @@ import {
isClassDevice, isClassDevice,
resolveClassScope, resolveClassScope,
} from '../../lib/access.js'; } from '../../lib/access.js';
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js'; import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js';
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js'; import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
import { emitToClass } from '../../realtime/bus.js'; import { emitToClass } from '../../realtime/bus.js';
@@ -182,6 +187,63 @@ export async function createHomework(user: TokenPayload, input: CreateHomeworkIn
return dto; 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( export async function updateHomework(
user: TokenPayload, user: TokenPayload,
homeworkId: string, homeworkId: string,
@@ -1,7 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { ApiError, sendOk } from '../../lib/http.js'; import { ApiError, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.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 { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
@@ -48,7 +48,7 @@ function resolveImportKind(value: unknown): (typeof IMPORT_KINDS)[number] {
* GET /api/imports/template?kind=grades|students|teachers&format=csv|xlsx - 下载导入模板 * 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); const kind = resolveImportKind(req.query.kind);
assertImportKindAllowed(kind, getAuthUser(req).role); assertImportKindAllowed(kind, getAuthUser(req).role);
const format = req.query.format === 'xlsx' ? 'xlsx' : 'csv'; const format = req.query.format === 'xlsx' ? 'xlsx' : 'csv';
@@ -72,7 +72,7 @@ router.get('/template', requireRole('ADMIN', 'TEACHER'), (req, res) => {
*/ */
router.post( router.post(
'/table/preview', '/table/preview',
requireRole('ADMIN', 'TEACHER'), requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'),
validate({ body: tableFileSchema }), validate({ body: tableFileSchema }),
(req, res) => { (req, res) => {
const input = validatedBody<TableFileInput>(req); const input = validatedBody<TableFileInput>(req);
@@ -89,7 +89,7 @@ router.post(
*/ */
router.post( router.post(
'/table/commit', '/table/commit',
requireRole('ADMIN', 'TEACHER'), requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'),
validate({ body: tableCommitSchema }), validate({ body: tableCommitSchema }),
async (req, res) => { async (req, res) => {
const input = validatedBody<TableCommitInput>(req); const input = validatedBody<TableCommitInput>(req);
@@ -7,7 +7,7 @@ import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js'; import { logger } from '../../lib/logger.js';
import { hashPassword } from '../../lib/password.js'; import { hashPassword } from '../../lib/password.js';
import { resolveUserClassRole } from '../../lib/access.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'; 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 byName = new Map(students.map((item) => [item.name.toLowerCase(), item]));
const courses = await prisma.course.findMany({ const courses = await prisma.course.findMany({
where: { classId }, 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 errors: ImportRowError[] = [];
const warnings: string[] = []; const warnings: string[] = [];
let inserted = 0; let inserted = 0;
@@ -390,6 +400,23 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
if (courseName && !course) if (courseName && !course)
warnings.push(`第 ${rowNumber} 行:科目「${courseName}」不存在,已按"不指定科目"导入`); 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 levelType = levelText ? 'custom' : 'percent';
const existing = await prisma.grade.findFirst({ const existing = await prisma.grade.findFirst({
@@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { ApiError, sendCreated, sendOk } from '../../lib/http.js'; 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 { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import * as gradeService from '../grades/grades.service.js'; import * as gradeService from '../grades/grades.service.js';
@@ -117,7 +117,7 @@ router.use(authenticate());
*/ */
router.get( router.get(
'/devices', '/devices',
requireRole('ADMIN', 'TEACHER'), requirePermission('device:manage'),
validate({ query: listDevicesQuerySchema }), validate({ query: listDevicesQuerySchema }),
async (req, res) => { async (req, res) => {
const { classId } = validatedQuery<{ classId?: string }>(req); 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)), '获取在线状态成功'); sendOk(res, await integrationService.listClassHelperStatus(getAuthUser(req)), '获取在线状态成功');
}); });
/** POST /api/integrations/devices - 新建设备并一次性返回明文令牌(管理员或本班班主任) */ /** POST /api/integrations/devices - 新建设备并一次性返回明文令牌(管理员或本班班主任) */
router.post( router.post(
'/devices', '/devices',
requireRole('ADMIN', 'TEACHER'), requirePermission('device:manage'),
validate({ body: createDeviceSchema }), validate({ body: createDeviceSchema }),
async (req, res) => { async (req, res) => {
const result = await integrationService.createDevice( const result = await integrationService.createDevice(
@@ -152,7 +152,7 @@ router.post(
/** PATCH /api/integrations/devices/:id - 改名 / 启停 / 调整同步开关 */ /** PATCH /api/integrations/devices/:id - 改名 / 启停 / 调整同步开关 */
router.patch( router.patch(
'/devices/:id', '/devices/:id',
requireRole('ADMIN', 'TEACHER'), requirePermission('device:manage'),
validate({ params: deviceIdParamSchema, body: updateDeviceSchema }), validate({ params: deviceIdParamSchema, body: updateDeviceSchema }),
async (req, res) => { async (req, res) => {
const { id } = validatedParams<{ id: string }>(req); const { id } = validatedParams<{ id: string }>(req);
@@ -174,7 +174,7 @@ router.patch(
*/ */
router.post( router.post(
'/devices/:id/request-schedule', '/devices/:id/request-schedule',
requireRole('ADMIN', 'TEACHER'), requirePermission('device:manage'),
validate({ params: deviceIdParamSchema }), validate({ params: deviceIdParamSchema }),
async (req, res) => { async (req, res) => {
const { id } = validatedParams<{ id: string }>(req); const { id } = validatedParams<{ id: string }>(req);
@@ -186,7 +186,7 @@ router.post(
/** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */ /** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */
router.post( router.post(
'/devices/:id/token', '/devices/:id/token',
requireRole('ADMIN', 'TEACHER'), requirePermission('device:manage'),
validate({ params: deviceIdParamSchema }), validate({ params: deviceIdParamSchema }),
async (req, res) => { async (req, res) => {
const { id } = validatedParams<{ id: string }>(req); const { id } = validatedParams<{ id: string }>(req);
@@ -197,7 +197,7 @@ router.post(
/** DELETE /api/integrations/devices/:id - 删除设备 */ /** DELETE /api/integrations/devices/:id - 删除设备 */
router.delete( router.delete(
'/devices/:id', '/devices/:id',
requireRole('ADMIN', 'TEACHER'), requirePermission('device:manage'),
validate({ params: deviceIdParamSchema }), validate({ params: deviceIdParamSchema }),
async (req, res) => { async (req, res) => {
const { id } = validatedParams<{ id: string }>(req); const { id } = validatedParams<{ id: string }>(req);
@@ -209,7 +209,7 @@ router.delete(
/** POST /api/integrations/classisland/notify - 把提醒下发到该班的 ClassIsland 设备 */ /** POST /api/integrations/classisland/notify - 把提醒下发到该班的 ClassIsland 设备 */
router.post( router.post(
'/classisland/notify', '/classisland/notify',
requireRole('ADMIN', 'TEACHER'), requirePermission('classisland:notify'),
validate({ body: sendNotificationSchema }), validate({ body: sendNotificationSchema }),
async (req, res) => { async (req, res) => {
const result = await integrationService.sendNotification( const result = await integrationService.sendNotification(
@@ -1,32 +1,46 @@
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js'; import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.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 { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
bulkCreateNotificationSchema,
createNotificationSchema, createNotificationSchema,
listNotificationsQuerySchema, listNotificationsQuerySchema,
notificationArchiveQuerySchema,
notificationScopeQuerySchema, notificationScopeQuerySchema,
type BulkCreateNotificationInput,
type CreateNotificationInput, type CreateNotificationInput,
type ListNotificationQuery,
type NotificationArchiveQuery,
} from './notifications.schemas.js'; } from './notifications.schemas.js';
import * as notificationService from './notifications.service.js'; import * as notificationService from './notifications.service.js';
const router = Router(); const router = Router();
router.use(authenticate()); 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) => { router.get('/', validate({ query: listNotificationsQuerySchema }), async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
const query = validatedQuery<{ const query = validatedQuery<ListNotificationQuery>(req);
classId?: string;
priority?: string;
unreadOnly: boolean;
keyword?: string;
}>(req);
sendOk(res, await notificationService.listNotifications(user, query), '获取通知列表成功'); 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= - 未读数(客户端红点) */ /** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */
router.get('/unread-count', validate({ query: notificationScopeQuerySchema }), async (req, res) => { router.get('/unread-count', validate({ query: notificationScopeQuerySchema }), async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -44,7 +58,7 @@ router.post('/read-all', validate({ body: notificationScopeQuerySchema }), async
/** POST /api/notifications - 发布通知(发布后广播 notification:new) */ /** POST /api/notifications - 发布通知(发布后广播 notification:new) */
router.post( router.post(
'/', '/',
requireRole('ADMIN', 'TEACHER'), requirePermission('notification:publish'),
validate({ body: createNotificationSchema }), validate({ body: createNotificationSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); 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 - 标记已读 */ /** POST /api/notifications/:id/read - 标记已读 */
router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) => { router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -63,7 +92,7 @@ router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) =
/** DELETE /api/notifications/:id - 删除通知 */ /** DELETE /api/notifications/:id - 删除通知 */
router.delete( router.delete(
'/:id', '/:id',
requireRole('ADMIN', 'TEACHER'), requirePermission('notification:delete'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -1,13 +1,36 @@
import { z } from 'zod'; import { z } from 'zod';
import { isDayKey } from '@classhelper/shared';
import { booleanFlagSchema } from '../../lib/schemas.js'; import { booleanFlagSchema } from '../../lib/schemas.js';
export const notificationPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']); 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({ export const listNotificationsQuerySchema = z.object({
classId: z.string().min(1).optional(), classId: z.string().min(1).optional(),
priority: notificationPrioritySchema.optional(), priority: notificationPrioritySchema.optional(),
unreadOnly: booleanFlagSchema, unreadOnly: booleanFlagSchema,
keyword: z.string().trim().max(64).optional(), 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({ export const createNotificationSchema = z.object({
@@ -26,5 +49,25 @@ export const notificationScopeQuerySchema = z.object({
classId: z.string().min(1).optional(), 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 CreateNotificationInput = z.infer<typeof createNotificationSchema>;
export type NotificationPriorityInput = z.infer<typeof notificationPrioritySchema>; 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 { import {
assertClassAccess, assertClassAccess,
assertCanPublishNotification, assertCanPublishNotification,
@@ -23,6 +34,37 @@ export interface ListNotificationOptions {
priority?: string; priority?: string;
unreadOnly?: boolean; unreadOnly?: boolean;
keyword?: string; 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( export async function listNotifications(
@@ -38,7 +80,7 @@ export async function listNotifications(
const notifications = await prisma.notification.findMany({ const notifications = await prisma.notification.findMany({
where: { where: {
...classScopeWhere(scope), ...classScopeWhere(scope),
...(options.priority ? { priority: options.priority } : {}), ...archiveWhere(options),
...(options.keyword ...(options.keyword
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: 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 })); 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} 房间。 * 发布通知并实时广播到 class:{classId} 房间。
* *
@@ -92,6 +226,8 @@ export async function createNotification(
content: input.content, content: input.content,
priority, priority,
createdBy: user.sub, createdBy: user.sub,
// 按天归档用「本地日期」:晚间发布的通知不会被 UTC 切片算到第二天(与作业同口径)
assignDate: dayKeyLocal(new Date()),
}, },
include: { creator: creatorSelect, reads: true }, 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 { archivesModule } from './archives/archives.module.js';
import { authModule } from './auth/auth.module.js'; import { authModule } from './auth/auth.module.js';
import { callsModule } from './calls/calls.module.js'; import { callsModule } from './calls/calls.module.js';
import { classesModule } from './classes/classes.module.js'; import { classesModule } from './classes/classes.module.js';
import { clientsModule } from './clients/clients.module.js';
import { coursesModule } from './courses/courses.module.js'; import { coursesModule } from './courses/courses.module.js';
import { databaseModule } from './database/database.module.js'; import { databaseModule } from './database/database.module.js';
import { dashboardModule } from './dashboard/dashboard.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 { importsModule } from './imports/imports.module.js';
import { integrationsModule } from './integrations/integrations.module.js'; import { integrationsModule } from './integrations/integrations.module.js';
import { notificationsModule } from './notifications/notifications.module.js'; import { notificationsModule } from './notifications/notifications.module.js';
import { permissionsModule } from './permissions/permissions.module.js';
import { schedulesModule } from './schedules/schedules.module.js'; import { schedulesModule } from './schedules/schedules.module.js';
import { studentsModule } from './students/students.module.js'; import { studentsModule } from './students/students.module.js';
import { systemModule } from './system/system.module.js';
import { teachersModule } from './teachers/teachers.module.js'; import { teachersModule } from './teachers/teachers.module.js';
import { termModule } from './term/term.module.js'; import { termModule } from './term/term.module.js';
import { updateModule } from './update/update.module.js'; import { updateModule } from './update/update.module.js';
@@ -26,16 +30,20 @@ import type { ApiModule } from './module.types.js';
*/ */
export const apiModules: ApiModule[] = [ export const apiModules: ApiModule[] = [
authModule, authModule,
aiModule,
classesModule, classesModule,
clientsModule,
coursesModule, coursesModule,
schedulesModule, schedulesModule,
homeworksModule, homeworksModule,
notificationsModule, notificationsModule,
permissionsModule,
callsModule, callsModule,
importsModule, importsModule,
integrationsModule, integrationsModule,
gradesModule, gradesModule,
studentsModule, studentsModule,
systemModule,
teachersModule, teachersModule,
archivesModule, archivesModule,
termModule, termModule,
@@ -1,7 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js'; import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.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 { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
@@ -58,7 +58,7 @@ router.get('/current', validate({ query: classStatusQuerySchema }), async (req,
/** POST /api/schedules - 新增课表 */ /** POST /api/schedules - 新增课表 */
router.post( router.post(
'/', '/',
requireRole('ADMIN', 'TEACHER'), requirePermission('schedule:write'),
validate({ body: createScheduleSchema }), validate({ body: createScheduleSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -73,7 +73,7 @@ router.post(
/** PATCH /api/schedules/:id - 修改课表 */ /** PATCH /api/schedules/:id - 修改课表 */
router.patch( router.patch(
'/:id', '/:id',
requireRole('ADMIN', 'TEACHER'), requirePermission('schedule:write'),
validate({ params: idParamSchema, body: updateScheduleSchema }), validate({ params: idParamSchema, body: updateScheduleSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -89,7 +89,7 @@ router.patch(
/** DELETE /api/schedules/:id - 删除课表 */ /** DELETE /api/schedules/:id - 删除课表 */
router.delete( router.delete(
'/:id', '/:id',
requireRole('ADMIN', 'TEACHER'), requirePermission('schedule:write'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -1,7 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js'; import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.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 { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
@@ -33,7 +33,7 @@ router.use(authenticate());
*/ */
router.get( router.get(
'/', '/',
requireRole('ADMIN', 'TEACHER'), requirePermission('roster:manage'),
validate({ query: listStudentsQuerySchema }), validate({ query: listStudentsQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -45,7 +45,7 @@ router.get(
/** GET /api/students/transfers - 调班 / 转出历史(管理员看全部,也可按学生或班级过滤) */ /** GET /api/students/transfers - 调班 / 转出历史(管理员看全部,也可按学生或班级过滤) */
router.get( router.get(
'/transfers', '/transfers',
requireRole('ADMIN', 'TEACHER'), requirePermission('roster:manage'),
validate({ query: listTransfersQuerySchema }), validate({ query: listTransfersQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -62,7 +62,7 @@ router.get(
*/ */
router.post( router.post(
'/transfer', '/transfer',
requireRole('ADMIN'), requirePermission('class:manage'),
validate({ body: transferStudentsSchema }), validate({ body: transferStudentsSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -74,7 +74,7 @@ router.post(
/** POST /api/students/transfer-out - 学生转出(学籍离开本校,不是调班) */ /** POST /api/students/transfer-out - 学生转出(学籍离开本校,不是调班) */
router.post( router.post(
'/transfer-out', '/transfer-out',
requireRole('ADMIN'), requirePermission('class:manage'),
validate({ body: transferOutStudentsSchema }), validate({ body: transferOutStudentsSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -86,7 +86,7 @@ router.post(
/** GET /api/students/:id/transfers - 单个学生的调班 / 转出历史 */ /** GET /api/students/:id/transfers - 单个学生的调班 / 转出历史 */
router.get( router.get(
'/:id/transfers', '/:id/transfers',
requireRole('ADMIN', 'TEACHER'), requirePermission('roster:manage'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -99,7 +99,7 @@ router.get(
/** POST /api/students - 新建学生(名单记录,不是账号) */ /** POST /api/students - 新建学生(名单记录,不是账号) */
router.post( router.post(
'/', '/',
requireRole('ADMIN', 'TEACHER'), requirePermission('roster:manage'),
validate({ body: createStudentSchema }), validate({ body: createStudentSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -114,7 +114,7 @@ router.post(
/** PATCH /api/students/:id - 修改学生信息 / 调班 */ /** PATCH /api/students/:id - 修改学生信息 / 调班 */
router.patch( router.patch(
'/:id', '/:id',
requireRole('ADMIN', 'TEACHER'), requirePermission('roster:manage'),
validate({ params: idParamSchema, body: updateStudentSchema }), validate({ params: idParamSchema, body: updateStudentSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -130,7 +130,7 @@ router.patch(
/** DELETE /api/students/:id - 删除学生名单记录 */ /** DELETE /api/students/:id - 删除学生名单记录 */
router.delete( router.delete(
'/:id', '/:id',
requireRole('ADMIN', 'TEACHER'), requirePermission('roster:manage'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); 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 { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js'; import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.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 { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
@@ -25,20 +25,30 @@ router.use(authenticate());
*/ */
/** GET /api/teachers?keyword= - 教师列表(教师管理页 + 分配协作教师时使用) */ /** GET /api/teachers?keyword= - 教师列表(教师管理页 + 分配协作教师时使用) */
router.get('/', requireRole('ADMIN'), validate({ query: listTeachersQuerySchema }), async (req, res) => { router.get(
'/',
requirePermission('teacher:manage'),
validate({ query: listTeachersQuerySchema }),
async (req, res) => {
const { keyword } = validatedQuery<{ keyword?: string }>(req); const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功'); sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功');
}); },
);
/** POST /api/teachers - 录入教师账号(仅管理员) */ /** POST /api/teachers - 录入教师账号(仅管理员) */
router.post('/', requireRole('ADMIN'), validate({ body: createTeacherSchema }), async (req, res) => { router.post(
'/',
requirePermission('teacher:manage'),
validate({ body: createTeacherSchema }),
async (req, res) => {
sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功'); sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功');
}); },
);
/** PATCH /api/teachers/:id - 编辑教师(姓名 / 用户名 / 角色) */ /** PATCH /api/teachers/:id - 编辑教师(姓名 / 用户名 / 角色) */
router.patch( router.patch(
'/:id', '/:id',
requireRole('ADMIN'), requirePermission('teacher:manage'),
validate({ params: idParamSchema, body: updateTeacherSchema }), validate({ params: idParamSchema, body: updateTeacherSchema }),
async (req, res) => { async (req, res) => {
const { id } = validatedParams<{ id: string }>(req); const { id } = validatedParams<{ id: string }>(req);
@@ -49,7 +59,7 @@ router.patch(
/** POST /api/teachers/:id/reset-password - 重置教师密码 */ /** POST /api/teachers/:id/reset-password - 重置教师密码 */
router.post( router.post(
'/:id/reset-password', '/:id/reset-password',
requireRole('ADMIN'), requirePermission('teacher:manage'),
validate({ params: idParamSchema, body: resetTeacherPasswordSchema }), validate({ params: idParamSchema, body: resetTeacherPasswordSchema }),
async (req, res) => { async (req, res) => {
const { id } = validatedParams<{ id: string }>(req); const { id } = validatedParams<{ id: string }>(req);
@@ -59,11 +69,16 @@ router.post(
); );
/** DELETE /api/teachers/:id - 删除教师账号(有班级职责/课程时拒绝,见 service) */ /** DELETE /api/teachers/:id - 删除教师账号(有班级职责/课程时拒绝,见 service) */
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => { router.delete(
'/:id',
requirePermission('teacher:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req); const { id } = validatedParams<{ id: string }>(req);
await teacherService.deleteTeacher(id); await teacherService.deleteTeacher(id);
sendOk(res, { id }, '教师账号已删除'); sendOk(res, { id }, '教师账号已删除');
}); },
);
export const teachersModule = defineModule({ export const teachersModule = defineModule({
name: 'teachers', name: 'teachers',
@@ -11,7 +11,7 @@ import type {
} from './teachers.schemas.js'; } from './teachers.schemas.js';
/** /**
* 教师管理(**仅管理员**,路由层已用 requireRole('ADMIN') 兜住): * 教师管理(**仅管理员**,路由层已用 requirePermission('teacher:manage') 兜住):
* 录入教师是管理员专属操作,班主任与科任老师都没有该权限。 * 录入教师是管理员专属操作,班主任与科任老师都没有该权限。
* 学生名单在 students 模块,两个模块结构保持一致(列表 / 新建 / 编辑 / 重置密码 / 删除)。 * 学生名单在 students 模块,两个模块结构保持一致(列表 / 新建 / 编辑 / 重置密码 / 删除)。
*/ */
@@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { sendOk } from '../../lib/http.js'; 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 { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
@@ -23,7 +23,7 @@ router.use(authenticate());
* - **写**:仅管理员 —— 周次是全校口径,改错会让所有班的课表整体错位; * - **写**:仅管理员 —— 周次是全校口径,改错会让所有班的课表整体错位;
* - `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。 * - `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 user = getAuthUser(req);
const { classId } = validatedQuery<{ classId?: string }>(req); const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功'); sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功');
@@ -32,7 +32,7 @@ router.get('/', requireRole('ADMIN', 'TEACHER'), validate({ query: termQuerySche
/** PUT /api/term - 保存逐周区间(整表覆盖,仅管理员) */ /** PUT /api/term - 保存逐周区间(整表覆盖,仅管理员) */
router.put( router.put(
'/', '/',
requireRole('ADMIN'), requirePermission('term:write'),
validate({ body: updateTermWeeksSchema }), validate({ body: updateTermWeeksSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -51,7 +51,7 @@ router.put(
*/ */
router.post( router.post(
'/auto', '/auto',
requireRole('ADMIN'), requirePermission('term:write'),
validate({ body: autoTermWeeksSchema }), validate({ body: autoTermWeeksSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -70,7 +70,7 @@ router.post(
*/ */
router.get( router.get(
'/holidays', '/holidays',
requireRole('ADMIN'), requirePermission('term:write'),
validate({ query: holidayQuerySchema }), validate({ query: holidayQuerySchema }),
async (req, res) => { async (req, res) => {
const { year } = validatedQuery<{ year?: number }>(req); 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 };
}
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more